@zhin.js/gateway 0.0.1-bootstrap.0 → 1.1.2
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/LICENSE +21 -0
- package/README.md +61 -0
- package/lib/config/ai-config-loader.d.ts +22 -0
- package/lib/config/ai-config-loader.js +96 -0
- package/lib/config/configuration-chat-policy.d.ts +57 -0
- package/lib/config/configuration-chat-policy.js +423 -0
- package/lib/config/configuration-patch.d.ts +16 -0
- package/lib/config/configuration-patch.js +105 -0
- package/lib/config/configuration-path.d.ts +6 -0
- package/lib/config/configuration-path.js +40 -0
- package/lib/config/configuration-projection.d.ts +6 -0
- package/lib/config/configuration-projection.js +28 -0
- package/lib/config/configuration-redaction.d.ts +18 -0
- package/lib/config/configuration-redaction.js +75 -0
- package/lib/config/configuration.d.ts +81 -0
- package/lib/config/configuration.js +686 -0
- package/lib/config/console-schema.d.ts +11 -0
- package/lib/config/console-schema.js +223 -0
- package/lib/config/environment-files.d.ts +3 -0
- package/lib/config/environment-files.js +14 -0
- package/lib/config/literal-secrets.d.ts +34 -0
- package/lib/config/literal-secrets.js +136 -0
- package/lib/config/plugin-package-map.d.ts +2 -0
- package/lib/config/plugin-package-map.js +14 -0
- package/lib/config/plugin-schema-catalog.d.ts +22 -0
- package/lib/config/plugin-schema-catalog.js +262 -0
- package/lib/config.d.ts +13 -0
- package/lib/config.js +13 -0
- package/lib/database/config.d.ts +22 -0
- package/lib/database/config.js +159 -0
- package/lib/database/index.d.ts +2 -0
- package/lib/database/index.js +2 -0
- package/lib/database/project-database.d.ts +31 -0
- package/lib/database/project-database.js +130 -0
- package/lib/database.d.ts +2 -0
- package/lib/database.js +2 -0
- package/lib/http/console-rpc.d.ts +27 -0
- package/lib/http/console-rpc.js +51 -0
- package/lib/http/gateway-console.d.ts +63 -0
- package/lib/http/gateway-console.js +309 -0
- package/lib/http/gateway-events.d.ts +40 -0
- package/lib/http/gateway-events.js +185 -0
- package/lib/http/gateway-http-server.d.ts +95 -0
- package/lib/http/gateway-http-server.js +522 -0
- package/lib/http/local-principal.d.ts +2 -0
- package/lib/http/local-principal.js +16 -0
- package/lib/http/plugin-switch-rpc.d.ts +19 -0
- package/lib/http/plugin-switch-rpc.js +214 -0
- package/lib/http/run-gateway.d.ts +43 -0
- package/lib/http/run-gateway.js +268 -0
- package/lib/http/start-gateway.d.ts +114 -0
- package/lib/http/start-gateway.js +226 -0
- package/lib/http-response.d.ts +6 -0
- package/lib/http-response.js +25 -0
- package/lib/http.d.ts +6 -0
- package/lib/http.js +6 -0
- package/lib/iam/crypto.d.ts +12 -0
- package/lib/iam/crypto.js +58 -0
- package/lib/iam/index.d.ts +8 -0
- package/lib/iam/index.js +8 -0
- package/lib/iam/install.d.ts +36 -0
- package/lib/iam/install.js +74 -0
- package/lib/iam/local-admin.d.ts +27 -0
- package/lib/iam/local-admin.js +102 -0
- package/lib/iam/rate-limit.d.ts +15 -0
- package/lib/iam/rate-limit.js +52 -0
- package/lib/iam/routes.d.ts +23 -0
- package/lib/iam/routes.js +157 -0
- package/lib/iam/rpc.d.ts +14 -0
- package/lib/iam/rpc.js +100 -0
- package/lib/iam/service.d.ts +203 -0
- package/lib/iam/service.js +784 -0
- package/lib/iam/setup-service.d.ts +83 -0
- package/lib/iam/setup-service.js +206 -0
- package/lib/iam/tables.d.ts +20 -0
- package/lib/iam/tables.js +82 -0
- package/lib/iam.d.ts +2 -0
- package/lib/iam.js +2 -0
- package/lib/index.d.ts +22 -0
- package/lib/index.js +22 -0
- package/lib/local/client.d.ts +71 -0
- package/lib/local/client.js +296 -0
- package/lib/local/handler.d.ts +30 -0
- package/lib/local/handler.js +86 -0
- package/lib/local/local-socket.d.ts +55 -0
- package/lib/local/local-socket.js +232 -0
- package/lib/local/paths.d.ts +34 -0
- package/lib/local/paths.js +58 -0
- package/lib/local/protocol.d.ts +140 -0
- package/lib/local/protocol.js +90 -0
- package/lib/local/state-file.d.ts +8 -0
- package/lib/local/state-file.js +45 -0
- package/lib/local.d.ts +10 -0
- package/lib/local.js +10 -0
- package/lib/onboarding/allowlist.d.ts +54 -0
- package/lib/onboarding/allowlist.js +106 -0
- package/lib/onboarding/errors.d.ts +7 -0
- package/lib/onboarding/errors.js +17 -0
- package/lib/onboarding/index.d.ts +6 -0
- package/lib/onboarding/index.js +6 -0
- package/lib/onboarding/install.d.ts +99 -0
- package/lib/onboarding/install.js +132 -0
- package/lib/onboarding/package-manager.d.ts +47 -0
- package/lib/onboarding/package-manager.js +179 -0
- package/lib/onboarding/service.d.ts +53 -0
- package/lib/onboarding/service.js +144 -0
- package/lib/onboarding/state-store.d.ts +26 -0
- package/lib/onboarding/state-store.js +97 -0
- package/lib/onboarding.d.ts +2 -0
- package/lib/onboarding.js +2 -0
- package/lib/packages/authorization.d.ts +24 -0
- package/lib/packages/authorization.js +128 -0
- package/lib/packages/errors.d.ts +11 -0
- package/lib/packages/errors.js +26 -0
- package/lib/packages/index.d.ts +5 -0
- package/lib/packages/index.js +5 -0
- package/lib/packages/rpc.d.ts +30 -0
- package/lib/packages/rpc.js +160 -0
- package/lib/packages/service.d.ts +139 -0
- package/lib/packages/service.js +504 -0
- package/lib/packages/snapshot.d.ts +14 -0
- package/lib/packages/snapshot.js +73 -0
- package/lib/packages.d.ts +2 -0
- package/lib/packages.js +2 -0
- package/lib/plugin-management/activity.d.ts +6 -0
- package/lib/plugin-management/activity.js +14 -0
- package/lib/plugin-management/config-explanation.d.ts +6 -0
- package/lib/plugin-management/config-explanation.js +113 -0
- package/lib/plugin-management/use-cases.d.ts +15 -0
- package/lib/plugin-management/use-cases.js +120 -0
- package/lib/plugin-management/workflow.d.ts +136 -0
- package/lib/plugin-management/workflow.js +878 -0
- package/lib/plugin-management.d.ts +5 -0
- package/lib/plugin-management.js +5 -0
- package/lib/project/config-file.d.ts +6 -0
- package/lib/project/config-file.js +58 -0
- package/lib/project/environment.d.ts +13 -0
- package/lib/project/environment.js +52 -0
- package/lib/project/instance-declarations.d.ts +55 -0
- package/lib/project/instance-declarations.js +542 -0
- package/lib/project/legacy-config.d.ts +25 -0
- package/lib/project/legacy-config.js +70 -0
- package/lib/project/node-requirements.d.ts +7 -0
- package/lib/project/node-requirements.js +20 -0
- package/lib/project/tsx-module-loader.d.ts +7 -0
- package/lib/project/tsx-module-loader.js +29 -0
- package/lib/project.d.ts +7 -0
- package/lib/project.js +7 -0
- package/lib/server.d.ts +11 -0
- package/lib/server.js +11 -0
- package/lib/supervisor/instance-logs.d.ts +39 -0
- package/lib/supervisor/instance-logs.js +152 -0
- package/lib/supervisor/instance-supervisor.d.ts +140 -0
- package/lib/supervisor/instance-supervisor.js +408 -0
- package/lib/supervisor/run-instance.d.ts +35 -0
- package/lib/supervisor/run-instance.js +161 -0
- package/lib/supervisor/terminal-state.d.ts +30 -0
- package/lib/supervisor/terminal-state.js +57 -0
- package/lib/supervisor.d.ts +4 -0
- package/lib/supervisor.js +4 -0
- package/lib/terminal/backends.d.ts +38 -0
- package/lib/terminal/backends.js +193 -0
- package/lib/terminal/config.d.ts +63 -0
- package/lib/terminal/config.js +237 -0
- package/lib/terminal/host.d.ts +26 -0
- package/lib/terminal/host.js +76 -0
- package/lib/terminal/index.d.ts +60 -0
- package/lib/terminal/index.js +135 -0
- package/lib/terminal/modules.d.ts +87 -0
- package/lib/terminal/modules.js +44 -0
- package/lib/terminal/service.d.ts +101 -0
- package/lib/terminal/service.js +455 -0
- package/lib/terminal/tickets.d.ts +46 -0
- package/lib/terminal/tickets.js +76 -0
- package/lib/terminal.d.ts +3 -0
- package/lib/terminal.js +3 -0
- package/lib/usage/aggregate.d.ts +87 -0
- package/lib/usage/aggregate.js +212 -0
- package/lib/usage.d.ts +2 -0
- package/lib/usage.js +2 -0
- package/package.json +117 -4
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gateway composition (ADR 0054 §1): the HTTP server and the instance supervisor, joined by a
|
|
3
|
+
* pluggable instance transport (the bridge supplies the IPC one). CLI wiring lives elsewhere.
|
|
4
|
+
*/
|
|
5
|
+
import { isLoopbackHost, resolveHttpConfig } from '@zhin.js/host-http';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
8
|
+
import { createConfigDocument } from '@zhin.js/runtime';
|
|
9
|
+
import { findConfigFile } from '../project/config-file.js';
|
|
10
|
+
import { loadRuntimeEnvironmentLayers } from '../project/environment.js';
|
|
11
|
+
import { InstanceSupervisor } from '../supervisor/instance-supervisor.js';
|
|
12
|
+
import { GatewayPackageService } from '../packages/service.js';
|
|
13
|
+
import { createGatewayRpcDispatcher } from '../packages/rpc.js';
|
|
14
|
+
import { createGatewayConsoleRpc } from './console-rpc.js';
|
|
15
|
+
import { createGatewayPluginSwitchRpc } from './plugin-switch-rpc.js';
|
|
16
|
+
import { startGatewayConsole } from './gateway-console.js';
|
|
17
|
+
import { registerGatewayEventRoutes } from './gateway-events.js';
|
|
18
|
+
import { GatewayHttpServer } from './gateway-http-server.js';
|
|
19
|
+
const logger = getLogger('gateway');
|
|
20
|
+
/**
|
|
21
|
+
* Reads and resolves the project's `http` config the same way the instance used to. Only the
|
|
22
|
+
* `http` block is read: plugin declarations are not validated here, so a config the instance
|
|
23
|
+
* rejects still leaves the Console reachable.
|
|
24
|
+
*/
|
|
25
|
+
export async function loadGatewayHttpConfig(projectRoot, environment = 'development') {
|
|
26
|
+
const file = findConfigFile(projectRoot);
|
|
27
|
+
if (!file)
|
|
28
|
+
return {};
|
|
29
|
+
const [document, layers] = await Promise.all([
|
|
30
|
+
createConfigDocument(join(projectRoot, file)).read().then((snapshot) => snapshot.document),
|
|
31
|
+
loadRuntimeEnvironmentLayers(projectRoot, environment),
|
|
32
|
+
]);
|
|
33
|
+
return resolveHttpConfig(document, layers.environments?.[environment], projectRoot);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Reads the `gateway` block. An invalid value is logged and ignored (the instance rejects the
|
|
37
|
+
* config on its own); like {@link loadGatewayHttpConfig}, it never keeps the gateway down.
|
|
38
|
+
*/
|
|
39
|
+
export async function loadGatewaySettings(projectRoot) {
|
|
40
|
+
const file = findConfigFile(projectRoot);
|
|
41
|
+
if (!file)
|
|
42
|
+
return {};
|
|
43
|
+
let block;
|
|
44
|
+
try {
|
|
45
|
+
block = (await createConfigDocument(join(projectRoot, file)).read()).document.gateway;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return {};
|
|
49
|
+
}
|
|
50
|
+
if (block === undefined || block === null)
|
|
51
|
+
return {};
|
|
52
|
+
const readyTimeout = typeof block === 'object' && !Array.isArray(block) ? block.readyTimeout : undefined;
|
|
53
|
+
if (readyTimeout === undefined)
|
|
54
|
+
return {};
|
|
55
|
+
if (typeof readyTimeout === 'number' && Number.isInteger(readyTimeout) && readyTimeout >= 1 && readyTimeout <= 3600) {
|
|
56
|
+
return Object.freeze({ readyTimeoutMs: readyTimeout * 1000 });
|
|
57
|
+
}
|
|
58
|
+
logger.warn(formatCompact({ op: 'gateway_config_invalid', key: 'gateway.readyTimeout', hint: 'integer seconds 1-3600; using 60' }));
|
|
59
|
+
return {};
|
|
60
|
+
}
|
|
61
|
+
export async function startGateway(options) {
|
|
62
|
+
const http = options.http ?? await loadGatewayHttpConfig(options.projectRoot, options.environment);
|
|
63
|
+
const supervisor = options.supervisor instanceof InstanceSupervisor
|
|
64
|
+
? options.supervisor
|
|
65
|
+
: new InstanceSupervisor(options.supervisor);
|
|
66
|
+
const transport = options.transport({ supervisor, projectRoot: options.projectRoot });
|
|
67
|
+
// `tokenRegistry` is read lazily: the server creates the default one.
|
|
68
|
+
let registry = options.tokenRegistry;
|
|
69
|
+
// gateway.readyTimeout is re-read before each start and transaction (ADR 0054 §4).
|
|
70
|
+
const readSettings = options.settings === false ? undefined : options.settings ?? (() => loadGatewaySettings(options.projectRoot));
|
|
71
|
+
let configuredReadyTimeoutMs;
|
|
72
|
+
const refreshSettings = async () => {
|
|
73
|
+
if (!readSettings)
|
|
74
|
+
return;
|
|
75
|
+
try {
|
|
76
|
+
configuredReadyTimeoutMs = (await readSettings()).readyTimeoutMs;
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return; // keep the previous value
|
|
80
|
+
}
|
|
81
|
+
supervisor.setReadyTimeout(configuredReadyTimeoutMs);
|
|
82
|
+
};
|
|
83
|
+
// Automatic restarts wait in `backoff` (crash) or for the old process to stop (`restart-request`,
|
|
84
|
+
// e.g. after a config change): refresh meanwhile.
|
|
85
|
+
supervisor.on('state', (status) => { if (status.state === 'backoff')
|
|
86
|
+
void refreshSettings(); });
|
|
87
|
+
supervisor.on('restart-request', () => { void refreshSettings(); });
|
|
88
|
+
const packages = options.packages === false
|
|
89
|
+
? undefined
|
|
90
|
+
: options.packages ?? new GatewayPackageService({
|
|
91
|
+
projectRoot: options.projectRoot,
|
|
92
|
+
instance: supervisor,
|
|
93
|
+
readyTimeoutMs: () => configuredReadyTimeoutMs ?? options.readyTimeoutMs,
|
|
94
|
+
beforeTransaction: refreshSettings,
|
|
95
|
+
hasOwner: () => registry?.initialized() ?? true,
|
|
96
|
+
});
|
|
97
|
+
let consoleStopped = false;
|
|
98
|
+
const lifecycle = {
|
|
99
|
+
start: async () => { await refreshSettings(); await supervisor.start(); consoleStopped = false; },
|
|
100
|
+
stop: async (stopOptions) => { consoleStopped = true; await supervisor.stop(stopOptions); },
|
|
101
|
+
restart: async (restartOptions) => { await refreshSettings(); await supervisor.restart(restartOptions); consoleStopped = false; },
|
|
102
|
+
status: () => supervisor.status(),
|
|
103
|
+
logs: supervisor.logs,
|
|
104
|
+
};
|
|
105
|
+
const rpc = createGatewayRpcDispatcher({
|
|
106
|
+
...(packages ? { packages } : {}),
|
|
107
|
+
instance: lifecycle,
|
|
108
|
+
});
|
|
109
|
+
const server = new GatewayHttpServer({
|
|
110
|
+
http,
|
|
111
|
+
instance: { transport: () => transport, state: () => supervisor.state },
|
|
112
|
+
...(options.tokenRegistry ? { tokenRegistry: options.tokenRegistry } : {}),
|
|
113
|
+
...(options.retryAfterSeconds !== undefined ? { retryAfterSeconds: options.retryAfterSeconds } : {}),
|
|
114
|
+
...(options.maxRequestBodyBytes !== undefined ? { maxRequestBodyBytes: options.maxRequestBodyBytes } : {}),
|
|
115
|
+
consoleRpc: createGatewayConsoleRpc(rpc),
|
|
116
|
+
});
|
|
117
|
+
registry = server.tokenRegistry;
|
|
118
|
+
// The Console's global plugin switch is a gateway transaction (stop → write → start, rollback);
|
|
119
|
+
// registered even without package management so it is never forwarded to the instance.
|
|
120
|
+
const disposePluginSwitch = server.useConsoleRpc(createGatewayPluginSwitchRpc({ projectRoot: options.projectRoot, packages }));
|
|
121
|
+
const disposeEvents = registerGatewayEventRoutes({
|
|
122
|
+
server,
|
|
123
|
+
...(packages ? { packages } : {}),
|
|
124
|
+
instance: { onChange: (listener) => transport.onRoutes(() => listener()) },
|
|
125
|
+
});
|
|
126
|
+
let gatewayConsole;
|
|
127
|
+
try {
|
|
128
|
+
// ADR 0054 §7: Console accounts live in the gateway's own connection to the project database.
|
|
129
|
+
if (options.console !== false) {
|
|
130
|
+
gatewayConsole = await startGatewayConsole({
|
|
131
|
+
...(options.console ?? {}),
|
|
132
|
+
server,
|
|
133
|
+
projectRoot: options.projectRoot,
|
|
134
|
+
...(options.environment ? { environment: options.environment } : {}),
|
|
135
|
+
instance: transport,
|
|
136
|
+
restartInstance: async () => {
|
|
137
|
+
if (supervisor.state === 'stopped')
|
|
138
|
+
return;
|
|
139
|
+
await refreshSettings();
|
|
140
|
+
await supervisor.restart({ reason: 'database' });
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
await settleWithin(gatewayConsole.ready, options.databaseWaitMs ?? 10_000);
|
|
144
|
+
}
|
|
145
|
+
assertNotOpenOnNetwork(http, server.tokenRegistry);
|
|
146
|
+
// Settings for the first instance start, read before listening so it starts right away.
|
|
147
|
+
if (options.startInstance !== false)
|
|
148
|
+
await refreshSettings();
|
|
149
|
+
await server.listen();
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
disposeEvents();
|
|
153
|
+
disposePluginSwitch();
|
|
154
|
+
await gatewayConsole?.close().catch(() => undefined);
|
|
155
|
+
await transport.dispose?.();
|
|
156
|
+
supervisor.dispose();
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
transport.setAddresses?.(server.addresses);
|
|
160
|
+
const instanceStarted = options.startInstance === false
|
|
161
|
+
? Promise.resolve()
|
|
162
|
+
: supervisor.start().catch((error) => {
|
|
163
|
+
// The Console stays reachable to show the failure and the logs.
|
|
164
|
+
logger.warn(formatCompact({
|
|
165
|
+
op: 'gateway_instance_start_failed',
|
|
166
|
+
error: error instanceof Error ? error.message : String(error),
|
|
167
|
+
}));
|
|
168
|
+
});
|
|
169
|
+
let closeResult;
|
|
170
|
+
return Object.freeze({
|
|
171
|
+
http: server,
|
|
172
|
+
supervisor,
|
|
173
|
+
transport,
|
|
174
|
+
packages,
|
|
175
|
+
console: gatewayConsole,
|
|
176
|
+
rpc,
|
|
177
|
+
ownsInstanceLifecycle: () => consoleStopped || packages?.activeTransaction !== undefined,
|
|
178
|
+
instanceStarted,
|
|
179
|
+
close() {
|
|
180
|
+
closeResult ??= (async () => {
|
|
181
|
+
try {
|
|
182
|
+
await supervisor.stop({ reason: 'gateway-stop' }).catch(() => undefined);
|
|
183
|
+
await transport.dispose?.();
|
|
184
|
+
}
|
|
185
|
+
finally {
|
|
186
|
+
disposeEvents();
|
|
187
|
+
disposePluginSwitch();
|
|
188
|
+
await gatewayConsole?.close().catch(() => undefined);
|
|
189
|
+
supervisor.dispose();
|
|
190
|
+
await server.close();
|
|
191
|
+
}
|
|
192
|
+
})();
|
|
193
|
+
return closeResult;
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
/** Thrown when the gateway would serve an open Host on a network address. */
|
|
198
|
+
export class OpenHostOnNetworkError extends Error {
|
|
199
|
+
host;
|
|
200
|
+
code = 'ZHIN_OPEN_HOST_ON_NETWORK';
|
|
201
|
+
constructor(host) {
|
|
202
|
+
super(`http.host 为 ${host}(可从网络访问),但既没有初始化令牌(http.token),Console 也还没有所有者:`
|
|
203
|
+
+ '开放模式拥有全部权限,只允许监听本机地址。请运行 zhin doctor --fix 在 .env 生成 HTTP_TOKEN 并在配置里用 '
|
|
204
|
+
+ 'http.token: ${HTTP_TOKEN} 引用,或先运行 zhin console init 创建所有者;也可以把 http.host 改为 127.0.0.1。');
|
|
205
|
+
this.host = host;
|
|
206
|
+
this.name = 'OpenHostOnNetworkError';
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* An open Host (no bootstrap token, no owner yet) holds every permission and is only meant for
|
|
211
|
+
* this machine. The per-request loopback check cannot see through a same-host reverse proxy that
|
|
212
|
+
* adds no forwarding header, so a network-facing `http.host` must not start open (ADR 0049 §1).
|
|
213
|
+
*/
|
|
214
|
+
export function assertNotOpenOnNetwork(http, registry) {
|
|
215
|
+
if (registry.hasAnyToken() || isLoopbackHost(http.host))
|
|
216
|
+
return;
|
|
217
|
+
throw new OpenHostOnNetworkError(http.host ?? '');
|
|
218
|
+
}
|
|
219
|
+
/** Waits for `promise` at most `ms` (the gateway listens anyway; the work continues). */
|
|
220
|
+
function settleWithin(promise, ms) {
|
|
221
|
+
let timer;
|
|
222
|
+
return Promise.race([
|
|
223
|
+
promise.then(() => undefined, () => undefined),
|
|
224
|
+
new Promise((resolve) => { timer = setTimeout(resolve, ms); timer.unref?.(); }),
|
|
225
|
+
]).finally(() => clearTimeout(timer));
|
|
226
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare function parseBoundedInteger(value: string | null, fallback: number, minimum: number, maximum: number): number;
|
|
2
|
+
/** A complete HTML page response. */
|
|
3
|
+
export declare function htmlResponse(html: string, status?: number): Response;
|
|
4
|
+
/** One Server-Sent Events frame. */
|
|
5
|
+
export declare function sseFrame(type: string, data: unknown, id?: string): string;
|
|
6
|
+
export declare function normalizeBase(value: string): string;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export function parseBoundedInteger(value, fallback, minimum, maximum) {
|
|
2
|
+
if (value == null || value.trim() === '')
|
|
3
|
+
return fallback;
|
|
4
|
+
const parsed = Number(value);
|
|
5
|
+
if (!Number.isInteger(parsed))
|
|
6
|
+
return fallback;
|
|
7
|
+
return Math.min(maximum, Math.max(minimum, parsed));
|
|
8
|
+
}
|
|
9
|
+
/** A complete HTML page response. */
|
|
10
|
+
export function htmlResponse(html, status = 200) {
|
|
11
|
+
return new Response(html, { status, headers: { 'content-type': 'text/html; charset=utf-8' } });
|
|
12
|
+
}
|
|
13
|
+
/** One Server-Sent Events frame. */
|
|
14
|
+
export function sseFrame(type, data, id) {
|
|
15
|
+
return `${id ? `id: ${id}\n` : ''}event: ${type}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
16
|
+
}
|
|
17
|
+
export function normalizeBase(value) {
|
|
18
|
+
if (!value.startsWith('/'))
|
|
19
|
+
return `/${value}`;
|
|
20
|
+
// Linear trim instead of /\/+$/ (polynomial backtracking on long runs of '/').
|
|
21
|
+
let end = value.length;
|
|
22
|
+
while (end > 0 && value.charCodeAt(end - 1) === 0x2f)
|
|
23
|
+
end -= 1;
|
|
24
|
+
return value.slice(0, end) || '/api';
|
|
25
|
+
}
|
package/lib/http.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@zhin.js/gateway/http` — HTML / SSE response helpers shared by gateway and instance routes
|
|
3
|
+
* (JSON: `jsonResponse` from `@zhin.js/host-http`). The instance imports this subpath, so it must
|
|
4
|
+
* not load the Node HTTP stack; the gateway HTTP server is `@zhin.js/gateway/server`.
|
|
5
|
+
*/
|
|
6
|
+
export * from './http-response.js';
|
package/lib/http.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@zhin.js/gateway/http` — HTML / SSE response helpers shared by gateway and instance routes
|
|
3
|
+
* (JSON: `jsonResponse` from `@zhin.js/host-http`). The instance imports this subpath, so it must
|
|
4
|
+
* not load the Node HTTP stack; the gateway HTTP server is `@zhin.js/gateway/server`.
|
|
5
|
+
*/
|
|
6
|
+
export * from './http-response.js';
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare const PASSWORD_MIN_LENGTH = 10;
|
|
2
|
+
export declare const PASSWORD_MAX_LENGTH = 1024;
|
|
3
|
+
export declare function passwordProblem(password: unknown): string | null;
|
|
4
|
+
/** `scrypt$N$r$p$salt$hash` (base64url). */
|
|
5
|
+
export declare function hashPassword(password: string): Promise<string>;
|
|
6
|
+
export declare function verifyPassword(password: string, encoded: string): Promise<boolean>;
|
|
7
|
+
/** Burns the same work as a real check so unknown usernames are not faster. */
|
|
8
|
+
export declare function verifyAgainstDummy(password: string): Promise<false>;
|
|
9
|
+
/** 32 random bytes, base64url (ADR 0049 §2). */
|
|
10
|
+
export declare function generateToken(): string;
|
|
11
|
+
export declare function sha256(value: string): string;
|
|
12
|
+
export declare function newId(): string;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { createHash, randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
/** ADR 0049 §2: scrypt N=2^15, r=8, p=1, 32-byte salt. */
|
|
3
|
+
const SCRYPT = Object.freeze({ N: 2 ** 15, r: 8, p: 1, keyLength: 32, maxmem: 64 * 1024 * 1024 });
|
|
4
|
+
export const PASSWORD_MIN_LENGTH = 10;
|
|
5
|
+
export const PASSWORD_MAX_LENGTH = 1024;
|
|
6
|
+
export function passwordProblem(password) {
|
|
7
|
+
if (typeof password !== 'string')
|
|
8
|
+
return '密码必须是字符串';
|
|
9
|
+
if (password.length < PASSWORD_MIN_LENGTH)
|
|
10
|
+
return `密码至少 ${PASSWORD_MIN_LENGTH} 位`;
|
|
11
|
+
if (password.length > PASSWORD_MAX_LENGTH)
|
|
12
|
+
return '密码过长';
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
function derive(password, salt, params) {
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
scrypt(password.normalize('NFKC'), salt, params.keyLength, {
|
|
18
|
+
N: params.N, r: params.r, p: params.p, maxmem: SCRYPT.maxmem,
|
|
19
|
+
}, (error, key) => (error ? reject(error) : resolve(key)));
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
/** `scrypt$N$r$p$salt$hash` (base64url). */
|
|
23
|
+
export async function hashPassword(password) {
|
|
24
|
+
const salt = randomBytes(32);
|
|
25
|
+
const key = await derive(password, salt, SCRYPT);
|
|
26
|
+
return ['scrypt', SCRYPT.N, SCRYPT.r, SCRYPT.p, salt.toString('base64url'), key.toString('base64url')].join('$');
|
|
27
|
+
}
|
|
28
|
+
export async function verifyPassword(password, encoded) {
|
|
29
|
+
const parts = encoded.split('$');
|
|
30
|
+
if (parts.length !== 6 || parts[0] !== 'scrypt')
|
|
31
|
+
return false;
|
|
32
|
+
const [N, r, p] = parts.slice(1, 4).map(Number);
|
|
33
|
+
if (![N, r, p].every(value => Number.isSafeInteger(value) && value > 0) || N > 2 ** 20)
|
|
34
|
+
return false;
|
|
35
|
+
const salt = Buffer.from(parts[4], 'base64url');
|
|
36
|
+
const expected = Buffer.from(parts[5], 'base64url');
|
|
37
|
+
if (salt.length < 16 || expected.length < 16)
|
|
38
|
+
return false;
|
|
39
|
+
const actual = await derive(password, salt, { N: N, r: r, p: p, keyLength: expected.length });
|
|
40
|
+
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
|
41
|
+
}
|
|
42
|
+
let dummyHash;
|
|
43
|
+
/** Burns the same work as a real check so unknown usernames are not faster. */
|
|
44
|
+
export async function verifyAgainstDummy(password) {
|
|
45
|
+
dummyHash ??= hashPassword('dummy-password-for-timing');
|
|
46
|
+
await verifyPassword(password, await dummyHash);
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
/** 32 random bytes, base64url (ADR 0049 §2). */
|
|
50
|
+
export function generateToken() {
|
|
51
|
+
return randomBytes(32).toString('base64url');
|
|
52
|
+
}
|
|
53
|
+
export function sha256(value) {
|
|
54
|
+
return createHash('sha256').update(value, 'utf8').digest('hex');
|
|
55
|
+
}
|
|
56
|
+
export function newId() {
|
|
57
|
+
return randomBytes(16).toString('hex');
|
|
58
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { installConsoleIam, projectEnvironment, type ConsoleIamInstallation, type InstallConsoleIamOptions } from './install.js';
|
|
2
|
+
export { accountInputProblem, ConsoleIamService, IamError, type BindingInput, type ConsoleAuditView, type ConsoleBindingView, type ConsoleCredentialRevocation, type ConsoleCredentialRevocationReason, type ConsoleIamServiceOptions, type ConsoleRoleView, type ConsoleTokenView, type ConsoleUserView, type IamDatabase, type IamErrorCode, } from './service.js';
|
|
3
|
+
export { ConsoleSetupService, SETUP_NO_MIGRATION_NOTICE, upsertDotenv, type ConsoleSetupServiceOptions, type SetupConfigPort, type SetupDatabaseField, type SetupDatabaseInput, type SetupDatabaseSummary, type SetupDriver, type SetupEnvFilePort, type SetupStatus, } from './setup-service.js';
|
|
4
|
+
export { defineConsoleIamTables, IAM_DIALECTS, IAM_TABLE_DEFINITIONS, IAM_TABLES } from './tables.js';
|
|
5
|
+
export { dispatchIamRpc, isIamRpc } from './rpc.js';
|
|
6
|
+
export { registerConsoleAuthRoutes } from './routes.js';
|
|
7
|
+
export { LoginRateLimiter } from './rate-limit.js';
|
|
8
|
+
export { createLocalConsoleAdmin, type LocalConsoleAdmin, type LocalConsoleAdminOptions, type LocalConsoleAdminResult } from './local-admin.js';
|
package/lib/iam/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { installConsoleIam, projectEnvironment } from './install.js';
|
|
2
|
+
export { accountInputProblem, ConsoleIamService, IamError, } from './service.js';
|
|
3
|
+
export { ConsoleSetupService, SETUP_NO_MIGRATION_NOTICE, upsertDotenv, } from './setup-service.js';
|
|
4
|
+
export { defineConsoleIamTables, IAM_DIALECTS, IAM_TABLE_DEFINITIONS, IAM_TABLES } from './tables.js';
|
|
5
|
+
export { dispatchIamRpc, isIamRpc } from './rpc.js';
|
|
6
|
+
export { registerConsoleAuthRoutes } from './routes.js';
|
|
7
|
+
export { LoginRateLimiter } from './rate-limit.js';
|
|
8
|
+
export { createLocalConsoleAdmin } from './local-admin.js';
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { HttpHost } from '@zhin.js/host-http';
|
|
2
|
+
import { ConsoleIamService, type IamDatabase } from './service.js';
|
|
3
|
+
import { ConsoleSetupService, type SetupConfigPort } from './setup-service.js';
|
|
4
|
+
import { type IamTableHost } from './tables.js';
|
|
5
|
+
export interface InstallConsoleIamOptions {
|
|
6
|
+
/** Process database; tables are defined here, so call before it starts. */
|
|
7
|
+
readonly database: IamDatabase & IamTableHost;
|
|
8
|
+
/** Config file writer (ConsoleConfigurationStore). */
|
|
9
|
+
readonly configStore: SetupConfigPort;
|
|
10
|
+
readonly projectRoot: string;
|
|
11
|
+
readonly apiBase?: string;
|
|
12
|
+
readonly logger?: Readonly<{
|
|
13
|
+
warn(message: string): void;
|
|
14
|
+
}>;
|
|
15
|
+
/** Process restart after the setup wizard changed the database. */
|
|
16
|
+
readonly requestRestart?: () => void;
|
|
17
|
+
/** `${…}` lookup for setup validation (defaults to process env + project `.env`). */
|
|
18
|
+
readonly environment?: () => Promise<Readonly<Record<string, string | undefined>>>;
|
|
19
|
+
}
|
|
20
|
+
export interface ConsoleIamInstallation {
|
|
21
|
+
readonly service: ConsoleIamService;
|
|
22
|
+
readonly setup: ConsoleSetupService;
|
|
23
|
+
/** Whether this database dialect can hold accounts. */
|
|
24
|
+
readonly supported: boolean;
|
|
25
|
+
/** Call once the database has started (idempotent). */
|
|
26
|
+
activate(): Promise<void>;
|
|
27
|
+
dispose(): void;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The single wiring point of Console access control (ADR 0049) into a Host process:
|
|
31
|
+
* tables, the session resolver behind `http.tokenRegistry`, `/auth/*` routes and the setup flow.
|
|
32
|
+
* Depends only on host-http, the database port and config/env file IO.
|
|
33
|
+
*/
|
|
34
|
+
export declare function installConsoleIam(http: HttpHost, options: InstallConsoleIamOptions): ConsoleIamInstallation;
|
|
35
|
+
/** process env overlaid with the project `.env` (what `${…}` in config resolves against). */
|
|
36
|
+
export declare function projectEnvironment(projectRoot: string): Promise<Readonly<Record<string, string | undefined>>>;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { readEnvFile, writeEnvFile } from '../config/environment-files.js';
|
|
3
|
+
import { normalizeBase } from '../http-response.js';
|
|
4
|
+
import { registerConsoleAuthRoutes } from './routes.js';
|
|
5
|
+
import { ConsoleIamService } from './service.js';
|
|
6
|
+
import { ConsoleSetupService } from './setup-service.js';
|
|
7
|
+
import { defineConsoleIamTables } from './tables.js';
|
|
8
|
+
/**
|
|
9
|
+
* The single wiring point of Console access control (ADR 0049) into a Host process:
|
|
10
|
+
* tables, the session resolver behind `http.tokenRegistry`, `/auth/*` routes and the setup flow.
|
|
11
|
+
* Depends only on host-http, the database port and config/env file IO.
|
|
12
|
+
*/
|
|
13
|
+
export function installConsoleIam(http, options) {
|
|
14
|
+
const supported = defineConsoleIamTables(options.database);
|
|
15
|
+
const service = new ConsoleIamService({
|
|
16
|
+
database: options.database,
|
|
17
|
+
projectRoot: options.projectRoot,
|
|
18
|
+
onError: error => options.logger?.warn(`console iam: ${error instanceof Error ? error.message : String(error)}`),
|
|
19
|
+
// Installed on the token registry by the runtime composition root; empty until then.
|
|
20
|
+
pluginPermissions: () => http.tokenRegistry.pluginPermissions(),
|
|
21
|
+
});
|
|
22
|
+
const setup = new ConsoleSetupService({
|
|
23
|
+
projectRoot: options.projectRoot,
|
|
24
|
+
config: options.configStore,
|
|
25
|
+
envFile: {
|
|
26
|
+
read: () => readEnvFile(options.projectRoot, '.env'),
|
|
27
|
+
write: content => writeEnvFile(options.projectRoot, '.env', content),
|
|
28
|
+
},
|
|
29
|
+
iam: () => (supported ? service : undefined),
|
|
30
|
+
environment: options.environment ?? (() => projectEnvironment(options.projectRoot)),
|
|
31
|
+
});
|
|
32
|
+
const disposers = [];
|
|
33
|
+
if (supported)
|
|
34
|
+
disposers.push(http.tokenRegistry.useAccessProvider(service));
|
|
35
|
+
else
|
|
36
|
+
options.logger?.warn('console iam: the configured database cannot store Console users; run the setup wizard to choose sqlite/mysql/pg/mongodb');
|
|
37
|
+
disposers.push(...registerConsoleAuthRoutes({
|
|
38
|
+
http,
|
|
39
|
+
base: normalizeBase(options.apiBase ?? '/api'),
|
|
40
|
+
iam: () => (supported ? service : undefined),
|
|
41
|
+
setup,
|
|
42
|
+
...(options.requestRestart ? { requestRestart: options.requestRestart } : {}),
|
|
43
|
+
}));
|
|
44
|
+
let activation;
|
|
45
|
+
return Object.freeze({
|
|
46
|
+
service,
|
|
47
|
+
setup,
|
|
48
|
+
supported,
|
|
49
|
+
activate() {
|
|
50
|
+
if (!supported)
|
|
51
|
+
return Promise.resolve();
|
|
52
|
+
activation ??= service.start();
|
|
53
|
+
return activation;
|
|
54
|
+
},
|
|
55
|
+
dispose() {
|
|
56
|
+
service.stop();
|
|
57
|
+
for (const dispose of disposers.reverse())
|
|
58
|
+
dispose();
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
/** process env overlaid with the project `.env` (what `${…}` in config resolves against). */
|
|
63
|
+
export async function projectEnvironment(projectRoot) {
|
|
64
|
+
const { parse } = await import('dotenv');
|
|
65
|
+
const values = { ...process.env };
|
|
66
|
+
for (const file of ['.env', `.env.${process.env.NODE_ENV === 'production' ? 'production' : 'development'}`]) {
|
|
67
|
+
try {
|
|
68
|
+
const { readFile } = await import('node:fs/promises');
|
|
69
|
+
Object.assign(values, parse(await readFile(join(projectRoot, file), 'utf8')));
|
|
70
|
+
}
|
|
71
|
+
catch { /* missing layers are fine */ }
|
|
72
|
+
}
|
|
73
|
+
return values;
|
|
74
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type LocalGatewayError } from '../local/protocol.js';
|
|
2
|
+
import { type ConsoleIamService } from './service.js';
|
|
3
|
+
import type { ConsoleSetupService } from './setup-service.js';
|
|
4
|
+
export type LocalConsoleAdminResult = Readonly<{
|
|
5
|
+
ok: true;
|
|
6
|
+
data: unknown;
|
|
7
|
+
}> | Readonly<{
|
|
8
|
+
ok: false;
|
|
9
|
+
error: LocalGatewayError;
|
|
10
|
+
}>;
|
|
11
|
+
export interface LocalConsoleAdmin {
|
|
12
|
+
owns(type: string): boolean;
|
|
13
|
+
handle(type: string, data: unknown): Promise<LocalConsoleAdminResult>;
|
|
14
|
+
}
|
|
15
|
+
export interface LocalConsoleAdminOptions {
|
|
16
|
+
readonly setup: ConsoleSetupService;
|
|
17
|
+
readonly iam: () => ConsoleIamService | undefined;
|
|
18
|
+
/**
|
|
19
|
+
* After the setup database was written: reconnect the gateway to it (awaited, so the next
|
|
20
|
+
* request already uses it) and restart the instance (not awaited). Returns whether the instance
|
|
21
|
+
* is being restarted.
|
|
22
|
+
*/
|
|
23
|
+
readonly switchDatabase: () => Promise<{
|
|
24
|
+
restarting: boolean;
|
|
25
|
+
}>;
|
|
26
|
+
}
|
|
27
|
+
export declare function createLocalConsoleAdmin(options: LocalConsoleAdminOptions): LocalConsoleAdmin;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Socket-only Console administration (`gateway.console.*`, principal `'local'`): what
|
|
3
|
+
* `zhin console init` / `zhin console user …` do when a gateway runs (ADR 0049 §7, ADR 0053 §5).
|
|
4
|
+
* The gateway's own {@link ConsoleSetupService} and {@link ConsoleIamService} answer, so the CLI
|
|
5
|
+
* and the web wizard share one flow, a database switch is applied by the gateway itself and the
|
|
6
|
+
* owner state changes at once. Never reachable over HTTP (the local socket's owner check is the
|
|
7
|
+
* authentication).
|
|
8
|
+
*/
|
|
9
|
+
import { DATABASE_BUSY_RETRY_AFTER_SECONDS, isDatabaseBusyError } from '../database/project-database.js';
|
|
10
|
+
import { LOCAL_GATEWAY_RPC } from '../local/protocol.js';
|
|
11
|
+
import { IamError } from './service.js';
|
|
12
|
+
const TYPES = new Set([
|
|
13
|
+
LOCAL_GATEWAY_RPC.CONSOLE_SETUP_STATUS,
|
|
14
|
+
LOCAL_GATEWAY_RPC.CONSOLE_SETUP_DRIVERS,
|
|
15
|
+
LOCAL_GATEWAY_RPC.CONSOLE_SETUP_DATABASE_TEST,
|
|
16
|
+
LOCAL_GATEWAY_RPC.CONSOLE_SETUP_DATABASE,
|
|
17
|
+
LOCAL_GATEWAY_RPC.CONSOLE_SETUP_OWNER,
|
|
18
|
+
LOCAL_GATEWAY_RPC.CONSOLE_USER_LIST,
|
|
19
|
+
LOCAL_GATEWAY_RPC.CONSOLE_USER_CREATE,
|
|
20
|
+
LOCAL_GATEWAY_RPC.CONSOLE_USER_PASSWD,
|
|
21
|
+
LOCAL_GATEWAY_RPC.CONSOLE_USER_DISABLE,
|
|
22
|
+
]);
|
|
23
|
+
const ACTOR = 'local';
|
|
24
|
+
export function createLocalConsoleAdmin(options) {
|
|
25
|
+
const { setup } = options;
|
|
26
|
+
const requireIam = () => {
|
|
27
|
+
const iam = options.iam();
|
|
28
|
+
if (!iam?.ready)
|
|
29
|
+
throw new IamError('unavailable', '当前数据库不能存储 Console 用户,或尚未就绪;请先运行 zhin console init 选择 sqlite/mysql/pg/mongodb');
|
|
30
|
+
return iam;
|
|
31
|
+
};
|
|
32
|
+
const requireUser = async (iam, username) => {
|
|
33
|
+
const user = typeof username === 'string' ? await iam.findUser(username) : null;
|
|
34
|
+
if (!user)
|
|
35
|
+
throw new IamError('not_found', `用户不存在:${typeof username === 'string' ? username : ''}`);
|
|
36
|
+
return user;
|
|
37
|
+
};
|
|
38
|
+
const audit = (action, target) => {
|
|
39
|
+
options.iam()?.audit({ actor: ACTOR, action, ...(target ? { target } : {}), outcome: 'ok' });
|
|
40
|
+
};
|
|
41
|
+
async function run(type, input) {
|
|
42
|
+
switch (type) {
|
|
43
|
+
case LOCAL_GATEWAY_RPC.CONSOLE_SETUP_STATUS: return setup.status();
|
|
44
|
+
case LOCAL_GATEWAY_RPC.CONSOLE_SETUP_DRIVERS: return setup.drivers();
|
|
45
|
+
case LOCAL_GATEWAY_RPC.CONSOLE_SETUP_DATABASE_TEST:
|
|
46
|
+
return setup.testDatabase({ dialect: input.dialect, connection: input.connection });
|
|
47
|
+
case LOCAL_GATEWAY_RPC.CONSOLE_SETUP_DATABASE: {
|
|
48
|
+
const applied = await setup.applyDatabase({ dialect: input.dialect, connection: input.connection });
|
|
49
|
+
const { restarting } = await options.switchDatabase();
|
|
50
|
+
audit('auth.setup.database');
|
|
51
|
+
return { notice: applied.notice, switched: true, restarting };
|
|
52
|
+
}
|
|
53
|
+
case LOCAL_GATEWAY_RPC.CONSOLE_SETUP_OWNER: {
|
|
54
|
+
const { user } = await setup.createOwner({ username: input.username, password: input.password, displayName: input.displayName });
|
|
55
|
+
audit('auth.setup', `user:${user.id}`);
|
|
56
|
+
return { user };
|
|
57
|
+
}
|
|
58
|
+
case LOCAL_GATEWAY_RPC.CONSOLE_USER_LIST: return { users: await requireIam().listUsers() };
|
|
59
|
+
case LOCAL_GATEWAY_RPC.CONSOLE_USER_CREATE: {
|
|
60
|
+
const role = typeof input.role === 'string' && input.role ? input.role : 'viewer';
|
|
61
|
+
const user = await requireIam().createUser({
|
|
62
|
+
username: input.username, password: input.password, displayName: input.displayName,
|
|
63
|
+
bindings: [{ role, scopeKind: 'global' }],
|
|
64
|
+
});
|
|
65
|
+
audit('iam.users.create', `user:${user.id}`);
|
|
66
|
+
return { user };
|
|
67
|
+
}
|
|
68
|
+
case LOCAL_GATEWAY_RPC.CONSOLE_USER_PASSWD: {
|
|
69
|
+
const iam = requireIam();
|
|
70
|
+
const user = await requireUser(iam, input.username);
|
|
71
|
+
await iam.resetPassword(user.id, input.password);
|
|
72
|
+
audit('iam.users.resetPassword', `user:${user.id}`);
|
|
73
|
+
return { user };
|
|
74
|
+
}
|
|
75
|
+
case LOCAL_GATEWAY_RPC.CONSOLE_USER_DISABLE: {
|
|
76
|
+
const iam = requireIam();
|
|
77
|
+
const user = await requireUser(iam, input.username);
|
|
78
|
+
const updated = await iam.setDisabled(user.id, input.disabled !== false);
|
|
79
|
+
audit(updated.disabled ? 'iam.users.disable' : 'iam.users.enable', `user:${user.id}`);
|
|
80
|
+
return { user: updated };
|
|
81
|
+
}
|
|
82
|
+
default: throw new IamError('invalid_input', `Unknown RPC type: ${type}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return Object.freeze({
|
|
86
|
+
owns: (type) => TYPES.has(type),
|
|
87
|
+
async handle(type, data) {
|
|
88
|
+
const input = data && typeof data === 'object' && !Array.isArray(data) ? data : {};
|
|
89
|
+
try {
|
|
90
|
+
return { ok: true, data: await run(type, input) };
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
if (error instanceof IamError)
|
|
94
|
+
return { ok: false, error: { code: error.code, message: error.message, status: error.status } };
|
|
95
|
+
if (isDatabaseBusyError(error)) {
|
|
96
|
+
return { ok: false, error: { code: 'database_busy', message: `数据库正忙,请 ${DATABASE_BUSY_RETRY_AFTER_SECONDS} 秒后重试`, status: 503 } };
|
|
97
|
+
}
|
|
98
|
+
return { ok: false, error: { code: 'internal', message: error instanceof Error ? error.message : String(error), status: 500 } };
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Login throttling: 5 failures / 15 minutes per IP and per username, then exponential lockout. */
|
|
2
|
+
export interface LoginRateLimitOptions {
|
|
3
|
+
readonly maxFailures?: number;
|
|
4
|
+
readonly windowMs?: number;
|
|
5
|
+
readonly maxLockMs?: number;
|
|
6
|
+
readonly now?: () => number;
|
|
7
|
+
}
|
|
8
|
+
export declare class LoginRateLimiter {
|
|
9
|
+
#private;
|
|
10
|
+
constructor(options?: LoginRateLimitOptions);
|
|
11
|
+
/** Milliseconds until any of `keys` may try again; 0 when allowed. */
|
|
12
|
+
retryAfter(keys: readonly string[]): number;
|
|
13
|
+
fail(keys: readonly string[]): void;
|
|
14
|
+
succeed(keys: readonly string[]): void;
|
|
15
|
+
}
|