@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,52 @@
|
|
|
1
|
+
/** Login throttling: 5 failures / 15 minutes per IP and per username, then exponential lockout. */
|
|
2
|
+
export class LoginRateLimiter {
|
|
3
|
+
#buckets = new Map();
|
|
4
|
+
#max;
|
|
5
|
+
#window;
|
|
6
|
+
#maxLock;
|
|
7
|
+
#now;
|
|
8
|
+
constructor(options = {}) {
|
|
9
|
+
this.#max = options.maxFailures ?? 5;
|
|
10
|
+
this.#window = options.windowMs ?? 15 * 60_000;
|
|
11
|
+
this.#maxLock = options.maxLockMs ?? 24 * 60 * 60_000;
|
|
12
|
+
this.#now = options.now ?? Date.now;
|
|
13
|
+
}
|
|
14
|
+
/** Milliseconds until any of `keys` may try again; 0 when allowed. */
|
|
15
|
+
retryAfter(keys) {
|
|
16
|
+
const now = this.#now();
|
|
17
|
+
let wait = 0;
|
|
18
|
+
for (const key of keys) {
|
|
19
|
+
const bucket = this.#buckets.get(key);
|
|
20
|
+
if (bucket && bucket.lockedUntil > now)
|
|
21
|
+
wait = Math.max(wait, bucket.lockedUntil - now);
|
|
22
|
+
}
|
|
23
|
+
return wait;
|
|
24
|
+
}
|
|
25
|
+
fail(keys) {
|
|
26
|
+
const now = this.#now();
|
|
27
|
+
for (const key of keys) {
|
|
28
|
+
const bucket = this.#buckets.get(key) ?? { failures: [], lockedUntil: 0, lockouts: 0 };
|
|
29
|
+
bucket.failures = bucket.failures.filter(at => now - at < this.#window);
|
|
30
|
+
bucket.failures.push(now);
|
|
31
|
+
if (bucket.failures.length >= this.#max) {
|
|
32
|
+
// 15 min, 30 min, 60 min … capped.
|
|
33
|
+
bucket.lockedUntil = now + Math.min(this.#window * 2 ** bucket.lockouts, this.#maxLock);
|
|
34
|
+
bucket.lockouts += 1;
|
|
35
|
+
bucket.failures = [];
|
|
36
|
+
}
|
|
37
|
+
this.#buckets.set(key, bucket);
|
|
38
|
+
}
|
|
39
|
+
if (this.#buckets.size > 10_000)
|
|
40
|
+
this.#prune(now);
|
|
41
|
+
}
|
|
42
|
+
succeed(keys) {
|
|
43
|
+
for (const key of keys)
|
|
44
|
+
this.#buckets.delete(key);
|
|
45
|
+
}
|
|
46
|
+
#prune(now) {
|
|
47
|
+
for (const [key, bucket] of this.#buckets) {
|
|
48
|
+
if (bucket.lockedUntil <= now && bucket.failures.every(at => now - at >= this.#window))
|
|
49
|
+
this.#buckets.delete(key);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type HttpHost, type TokenRegistry } from '@zhin.js/host-http';
|
|
2
|
+
import { type ConsoleIamService } from './service.js';
|
|
3
|
+
import type { ConsoleSetupService } from './setup-service.js';
|
|
4
|
+
/** What `/auth/*` needs from a Host: the gateway's HTTP server or an in-process `HttpHost`. */
|
|
5
|
+
export interface ConsoleAuthRouteHost {
|
|
6
|
+
route: HttpHost['route'];
|
|
7
|
+
readonly tokenRegistry: TokenRegistry;
|
|
8
|
+
}
|
|
9
|
+
export interface RegisterConsoleAuthRoutesOptions {
|
|
10
|
+
readonly http: ConsoleAuthRouteHost;
|
|
11
|
+
readonly base: string;
|
|
12
|
+
readonly iam: () => ConsoleIamService | undefined;
|
|
13
|
+
readonly setup: ConsoleSetupService;
|
|
14
|
+
/** Restarts the process after the database changed (injected; never exits directly). */
|
|
15
|
+
readonly requestRestart?: () => void;
|
|
16
|
+
/**
|
|
17
|
+
* Onboarding state (ADR 0053 §4): added to `GET /auth/setup`, and to `/auth/me` for callers
|
|
18
|
+
* holding `plugin.manage`.
|
|
19
|
+
*/
|
|
20
|
+
readonly onboarding?: () => Promise<unknown>;
|
|
21
|
+
}
|
|
22
|
+
/** `/auth/*` (ADR 0049 §5, §7). The HTTP Host has already authenticated the caller. */
|
|
23
|
+
export declare function registerConsoleAuthRoutes(options: RegisterConsoleAuthRoutesOptions): Array<() => void>;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { CONSOLE_AUTH_ROUTES } from '@zhin.js/console-protocol';
|
|
2
|
+
import { HttpBodyError, globalPermissions, isUserPrincipal, jsonResponse, readJsonBody, requestMeta, } from '@zhin.js/host-http';
|
|
3
|
+
import { CONSOLE_PERMISSIONS } from '@zhin.js/console-protocol';
|
|
4
|
+
import { DATABASE_BUSY_RETRY_AFTER_SECONDS, isDatabaseBusyError } from '../database/project-database.js';
|
|
5
|
+
import { IamError } from './service.js';
|
|
6
|
+
const BODY_LIMIT = 16 * 1024;
|
|
7
|
+
/** `/auth/*` (ADR 0049 §5, §7). The HTTP Host has already authenticated the caller. */
|
|
8
|
+
export function registerConsoleAuthRoutes(options) {
|
|
9
|
+
const { http, base } = options;
|
|
10
|
+
const route = (method, path, handler, summary) => http.route(method, `${base}${path}`, async (request, { principal, remoteAddress }) => {
|
|
11
|
+
const noStore = { 'cache-control': 'no-store' };
|
|
12
|
+
try {
|
|
13
|
+
const result = await handler({
|
|
14
|
+
request,
|
|
15
|
+
meta: requestMeta(request, remoteAddress),
|
|
16
|
+
principal,
|
|
17
|
+
body: async () => {
|
|
18
|
+
const value = await readJsonBody(request, { limit: BODY_LIMIT });
|
|
19
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
return jsonResponse(result.status, result.body, { ...noStore, ...result.headers });
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
if (error instanceof HttpBodyError) {
|
|
26
|
+
return jsonResponse(error.statusCode, { success: false, error: error.message }, noStore);
|
|
27
|
+
}
|
|
28
|
+
if (error instanceof IamError) {
|
|
29
|
+
return jsonResponse(error.status, { success: false, code: error.code, error: error.message }, error.retryAfterMs ? { ...noStore, 'retry-after': String(Math.ceil(error.retryAfterMs / 1000)) } : noStore);
|
|
30
|
+
}
|
|
31
|
+
if (isDatabaseBusyError(error)) {
|
|
32
|
+
return jsonResponse(503, { success: false, code: 'database_busy', error: '数据库正忙,请稍后重试' }, { ...noStore, 'retry-after': String(DATABASE_BUSY_RETRY_AFTER_SECONDS) });
|
|
33
|
+
}
|
|
34
|
+
return jsonResponse(500, { success: false, code: 'failed', error: '操作失败' }, noStore);
|
|
35
|
+
}
|
|
36
|
+
}, { summary, tags: ['auth'] });
|
|
37
|
+
const requireIam = () => {
|
|
38
|
+
const iam = options.iam();
|
|
39
|
+
if (!iam?.ready)
|
|
40
|
+
throw new IamError('unavailable', '当前数据库不能存储账号,或尚未就绪');
|
|
41
|
+
return iam;
|
|
42
|
+
};
|
|
43
|
+
const requireUser = (principal) => {
|
|
44
|
+
if (!isUserPrincipal(principal))
|
|
45
|
+
throw new IamError('unauthorized', '需要登录');
|
|
46
|
+
return principal;
|
|
47
|
+
};
|
|
48
|
+
/** Setup is open only to the bootstrap principal while no owner exists. */
|
|
49
|
+
const requireSetup = (principal) => {
|
|
50
|
+
if (!options.setup.setupRequired())
|
|
51
|
+
throw new IamError('conflict', '已初始化');
|
|
52
|
+
if (principal?.kind !== 'bootstrap')
|
|
53
|
+
throw new IamError('forbidden', '需要初始化令牌');
|
|
54
|
+
};
|
|
55
|
+
const ok = (data, status = 200) => ({ status, body: { success: true, data } });
|
|
56
|
+
const onboarding = async () => {
|
|
57
|
+
if (!options.onboarding)
|
|
58
|
+
return {};
|
|
59
|
+
try {
|
|
60
|
+
return { onboarding: await options.onboarding() };
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return {};
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
const managesPlugins = (permissions) => permissions.includes(CONSOLE_PERMISSIONS.PLUGIN_MANAGE);
|
|
67
|
+
return [
|
|
68
|
+
route('POST', CONSOLE_AUTH_ROUTES.LOGIN, async ({ meta, body }) => {
|
|
69
|
+
const input = await body();
|
|
70
|
+
const result = await requireIam().login(input.username, input.password, meta);
|
|
71
|
+
return ok(result);
|
|
72
|
+
}, 'Console login'),
|
|
73
|
+
route('POST', CONSOLE_AUTH_ROUTES.LOGOUT, async ({ principal }) => {
|
|
74
|
+
await requireIam().logout(requireUser(principal));
|
|
75
|
+
return ok({ loggedOut: true });
|
|
76
|
+
}, 'Console logout'),
|
|
77
|
+
route('GET', CONSOLE_AUTH_ROUTES.ME, async ({ principal }) => {
|
|
78
|
+
const setupRequired = options.setup.setupRequired();
|
|
79
|
+
if (!principal)
|
|
80
|
+
return ok({ kind: 'anonymous', permissions: [], bindings: [], setupRequired });
|
|
81
|
+
if (principal.kind === 'bootstrap') {
|
|
82
|
+
const permissions = globalPermissions(principal);
|
|
83
|
+
return ok({
|
|
84
|
+
kind: 'bootstrap', open: principal.open, permissions, bindings: [], setupRequired,
|
|
85
|
+
...(managesPlugins(permissions) ? await onboarding() : {}),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
const user = await options.iam()?.getUser(principal.userId);
|
|
89
|
+
// Level grants expanded over the loaded plugins' permission catalog.
|
|
90
|
+
const permissions = globalPermissions(principal, options.http.tokenRegistry?.pluginPermissions?.() ?? []);
|
|
91
|
+
return ok({
|
|
92
|
+
kind: principal.kind,
|
|
93
|
+
user: user ? { id: user.id, username: user.username, displayName: user.displayName } : { id: principal.userId, username: principal.username, displayName: principal.displayName },
|
|
94
|
+
permissions,
|
|
95
|
+
bindings: principal.grants.map(grant => ({ role: grant.role, scope: grant.scope, permissions: grant.permissions })),
|
|
96
|
+
setupRequired,
|
|
97
|
+
...(managesPlugins(permissions) ? await onboarding() : {}),
|
|
98
|
+
});
|
|
99
|
+
}, 'Current Console principal'),
|
|
100
|
+
route('POST', CONSOLE_AUTH_ROUTES.PASSWORD, async ({ meta, principal, body }) => {
|
|
101
|
+
const input = await body();
|
|
102
|
+
const issued = await requireIam().changePassword(requireUser(principal), input.currentPassword, input.newPassword, meta);
|
|
103
|
+
return ok({ changed: true, ...issued });
|
|
104
|
+
}, 'Change own password'),
|
|
105
|
+
route('GET', CONSOLE_AUTH_ROUTES.TOKENS, async ({ principal }) => {
|
|
106
|
+
return ok({ tokens: await requireIam().listTokens(requireUser(principal).userId) });
|
|
107
|
+
}, 'List own API tokens'),
|
|
108
|
+
route('POST', CONSOLE_AUTH_ROUTES.TOKENS, async ({ principal, body }) => {
|
|
109
|
+
const user = requireUser(principal);
|
|
110
|
+
const input = await body();
|
|
111
|
+
const created = await requireIam().createToken(user.userId, { name: input.name, permissions: input.permissions, expiresAt: input.expiresAt });
|
|
112
|
+
requireIam().audit({ actor: `user:${user.userId}`, action: 'auth.tokens.create', target: `api-token:${created.record.id}`, outcome: 'ok' });
|
|
113
|
+
return ok(created, 201);
|
|
114
|
+
}, 'Create own API token'),
|
|
115
|
+
route('POST', CONSOLE_AUTH_ROUTES.TOKENS_REVOKE, async ({ principal, body }) => {
|
|
116
|
+
const user = requireUser(principal);
|
|
117
|
+
const input = await body();
|
|
118
|
+
await requireIam().revokeToken(String(input.id ?? ''), user.userId);
|
|
119
|
+
requireIam().audit({ actor: `user:${user.userId}`, action: 'auth.tokens.revoke', target: `api-token:${String(input.id ?? '')}`, outcome: 'ok' });
|
|
120
|
+
return ok({ revoked: true });
|
|
121
|
+
}, 'Revoke own API token'),
|
|
122
|
+
// ---------------------------------------------------------------- setup (bootstrap only)
|
|
123
|
+
route('GET', CONSOLE_AUTH_ROUTES.SETUP, async ({ principal }) => {
|
|
124
|
+
requireSetup(principal);
|
|
125
|
+
return ok({ ...await options.setup.status(), ...await onboarding() });
|
|
126
|
+
}, 'Setup status'),
|
|
127
|
+
route('POST', CONSOLE_AUTH_ROUTES.SETUP, async ({ meta, principal, body }) => {
|
|
128
|
+
requireSetup(principal);
|
|
129
|
+
const input = await body();
|
|
130
|
+
const { user } = await options.setup.createOwner({ username: input.username, password: input.password, displayName: input.displayName });
|
|
131
|
+
const iam = requireIam();
|
|
132
|
+
const session = await iam.issueSession(user.id, meta);
|
|
133
|
+
iam.audit({ actor: 'bootstrap', action: 'auth.setup', target: `user:${user.id}`, outcome: 'ok' });
|
|
134
|
+
return ok({ ...session, user }, 201);
|
|
135
|
+
}, 'Create the first owner'),
|
|
136
|
+
route('GET', CONSOLE_AUTH_ROUTES.SETUP_DATABASE, async ({ principal }) => {
|
|
137
|
+
requireSetup(principal);
|
|
138
|
+
const status = await options.setup.status();
|
|
139
|
+
return ok({ ...status.database, drivers: await options.setup.drivers() });
|
|
140
|
+
}, 'Setup database choices'),
|
|
141
|
+
route('POST', CONSOLE_AUTH_ROUTES.SETUP_DATABASE_TEST, async ({ principal, body }) => {
|
|
142
|
+
requireSetup(principal);
|
|
143
|
+
const input = await body();
|
|
144
|
+
return ok(await options.setup.testDatabase({ dialect: input.dialect, connection: input.connection }));
|
|
145
|
+
}, 'Test a database connection'),
|
|
146
|
+
route('POST', CONSOLE_AUTH_ROUTES.SETUP_DATABASE, async ({ principal, body }) => {
|
|
147
|
+
requireSetup(principal);
|
|
148
|
+
const input = await body();
|
|
149
|
+
const result = await options.setup.applyDatabase({ dialect: input.dialect, connection: input.connection });
|
|
150
|
+
if (!options.requestRestart)
|
|
151
|
+
return ok({ restarting: false, ...result });
|
|
152
|
+
// Respond first; the database is a process resource and needs a restart.
|
|
153
|
+
setTimeout(() => options.requestRestart?.(), 300).unref?.();
|
|
154
|
+
return ok({ restarting: true, notice: result.notice });
|
|
155
|
+
}, 'Save the setup database'),
|
|
156
|
+
];
|
|
157
|
+
}
|
package/lib/iam/rpc.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ConsolePrincipal } from '@zhin.js/host-http';
|
|
2
|
+
import { type BindingInput, type ConsoleIamService } from './service.js';
|
|
3
|
+
export interface IamRpcReply {
|
|
4
|
+
readonly status: number;
|
|
5
|
+
readonly body: Record<string, unknown>;
|
|
6
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
7
|
+
}
|
|
8
|
+
export declare function isIamRpc(type: unknown): boolean;
|
|
9
|
+
/**
|
|
10
|
+
* `iam.*` Console RPC. The caller has already passed the permission gate
|
|
11
|
+
* (`iam.view` / `iam.manage`); this only validates input and runs the operation.
|
|
12
|
+
*/
|
|
13
|
+
export declare function dispatchIamRpc(message: Readonly<Record<string, unknown>>, service: ConsoleIamService | undefined, principal: ConsolePrincipal): Promise<IamRpcReply>;
|
|
14
|
+
export declare function bindingsOf(value: unknown): readonly BindingInput[];
|
package/lib/iam/rpc.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { IAM_RPC, toConsoleIamPluginPermissions } from '@zhin.js/console-protocol';
|
|
2
|
+
import { DATABASE_BUSY_RETRY_AFTER_SECONDS, isDatabaseBusyError } from '../database/project-database.js';
|
|
3
|
+
import { IamError } from './service.js';
|
|
4
|
+
const IAM_TYPES = new Set(Object.values(IAM_RPC));
|
|
5
|
+
export function isIamRpc(type) {
|
|
6
|
+
return typeof type === 'string' && IAM_TYPES.has(type);
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* `iam.*` Console RPC. The caller has already passed the permission gate
|
|
10
|
+
* (`iam.view` / `iam.manage`); this only validates input and runs the operation.
|
|
11
|
+
*/
|
|
12
|
+
export async function dispatchIamRpc(message, service, principal) {
|
|
13
|
+
const requestId = message.requestId;
|
|
14
|
+
const type = String(message.type ?? '');
|
|
15
|
+
const payload = (message.data && typeof message.data === 'object' && !Array.isArray(message.data)
|
|
16
|
+
? message.data
|
|
17
|
+
: message);
|
|
18
|
+
const ok = (data) => ({ status: 200, body: { success: true, data, type, requestId } });
|
|
19
|
+
try {
|
|
20
|
+
if (!service?.ready)
|
|
21
|
+
throw new IamError('unavailable', '当前数据库不能存储账号,或尚未就绪');
|
|
22
|
+
const id = (key = 'id') => {
|
|
23
|
+
const value = payload[key];
|
|
24
|
+
if (typeof value !== 'string' || !value)
|
|
25
|
+
throw new IamError('invalid_input', `${key} 必填`);
|
|
26
|
+
return value;
|
|
27
|
+
};
|
|
28
|
+
switch (type) {
|
|
29
|
+
case IAM_RPC.USERS_LIST: return ok({ users: await service.listUsers() });
|
|
30
|
+
case IAM_RPC.USERS_CREATE: return ok({ user: await service.createUser({
|
|
31
|
+
username: payload.username, password: payload.password, displayName: payload.displayName,
|
|
32
|
+
bindings: bindingsOf(payload.bindings ?? []),
|
|
33
|
+
}) });
|
|
34
|
+
case IAM_RPC.USERS_UPDATE: return ok({ user: await service.updateUser(id(), { displayName: payload.displayName, disabled: payload.disabled }) });
|
|
35
|
+
case IAM_RPC.USERS_DISABLE: return ok({ user: await service.setDisabled(id(), payload.disabled !== false) });
|
|
36
|
+
case IAM_RPC.USERS_DELETE:
|
|
37
|
+
await service.deleteUser(id());
|
|
38
|
+
return ok({ deleted: true });
|
|
39
|
+
case IAM_RPC.USERS_RESET_PASSWORD:
|
|
40
|
+
await service.resetPassword(id(), payload.password);
|
|
41
|
+
return ok({ reset: true });
|
|
42
|
+
case IAM_RPC.ROLES_LIST: return ok({ roles: await service.listRoles(), pluginPermissions: toConsoleIamPluginPermissions(service.pluginPermissionCatalog()) });
|
|
43
|
+
case IAM_RPC.ROLES_CREATE: return ok(withWarnings(service, { role: await service.createRole({ name: payload.name, description: payload.description, permissions: payload.permissions }) }));
|
|
44
|
+
case IAM_RPC.ROLES_UPDATE: return ok(withWarnings(service, { role: await service.updateRole(id(), { description: payload.description, permissions: payload.permissions }) }));
|
|
45
|
+
case IAM_RPC.ROLES_DELETE:
|
|
46
|
+
await service.deleteRole(id());
|
|
47
|
+
return ok({ deleted: true });
|
|
48
|
+
case IAM_RPC.BINDINGS_SET: return ok({ user: await service.setBindings(id('userId'), bindingsOf(payload.bindings)) });
|
|
49
|
+
case IAM_RPC.TOKENS_LIST: return ok({ tokens: await service.listTokens(typeof payload.userId === 'string' ? payload.userId : undefined) });
|
|
50
|
+
case IAM_RPC.TOKENS_CREATE: {
|
|
51
|
+
const userId = typeof payload.userId === 'string' && payload.userId
|
|
52
|
+
? payload.userId
|
|
53
|
+
: principal.kind === 'bootstrap' ? id('userId') : principal.userId;
|
|
54
|
+
const created = await service.createToken(userId, { name: payload.name, permissions: payload.permissions, expiresAt: payload.expiresAt });
|
|
55
|
+
return ok({ token: created.token, record: created.record, ...pluginWarnings(service, created.record.permissions ?? []) });
|
|
56
|
+
}
|
|
57
|
+
case IAM_RPC.TOKENS_REVOKE:
|
|
58
|
+
await service.revokeToken(id());
|
|
59
|
+
return ok({ revoked: true });
|
|
60
|
+
case IAM_RPC.AUDIT_LIST: return ok(await service.listAudit({
|
|
61
|
+
actor: payload.actor, action: payload.action, since: payload.since, until: payload.until,
|
|
62
|
+
limit: payload.limit, before: payload.before,
|
|
63
|
+
}));
|
|
64
|
+
default: return { status: 400, body: { success: false, error: `Unknown RPC type: ${type}`, requestId } };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
if (error instanceof IamError) {
|
|
69
|
+
return { status: error.status, body: { success: false, code: error.code, error: error.message, requestId } };
|
|
70
|
+
}
|
|
71
|
+
if (isDatabaseBusyError(error)) {
|
|
72
|
+
return { status: 503, body: { success: false, code: 'database_busy', error: '数据库正忙,请稍后重试', requestId }, headers: { 'retry-after': String(DATABASE_BUSY_RETRY_AFTER_SECONDS) } };
|
|
73
|
+
}
|
|
74
|
+
return { status: 500, body: { success: false, code: 'failed', error: '访问控制操作失败', requestId } };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** `warnings` for plugin permissions no loaded plugin uses yet (accepted; they grant nothing until one does). */
|
|
78
|
+
function pluginWarnings(service, permissions) {
|
|
79
|
+
const unknown = service.unknownPluginPermissions(permissions);
|
|
80
|
+
return unknown.length ? { warnings: unknown.map(permission => `插件权限 ${permission} 当前没有已加载的插件使用`) } : {};
|
|
81
|
+
}
|
|
82
|
+
function withWarnings(service, data) {
|
|
83
|
+
return { ...data, ...pluginWarnings(service, data.role.permissions) };
|
|
84
|
+
}
|
|
85
|
+
export function bindingsOf(value) {
|
|
86
|
+
if (!Array.isArray(value))
|
|
87
|
+
throw new IamError('invalid_input', 'bindings 必须是数组');
|
|
88
|
+
return value.map(item => {
|
|
89
|
+
if (!item || typeof item !== 'object')
|
|
90
|
+
throw new IamError('invalid_input', 'binding 无效');
|
|
91
|
+
const record = item;
|
|
92
|
+
if (typeof record.role !== 'string')
|
|
93
|
+
throw new IamError('invalid_input', 'binding.role 必填');
|
|
94
|
+
return {
|
|
95
|
+
role: record.role,
|
|
96
|
+
...(typeof record.scopeKind === 'string' ? { scopeKind: record.scopeKind } : {}),
|
|
97
|
+
...(typeof record.scopeKey === 'string' ? { scopeKey: record.scopeKey } : {}),
|
|
98
|
+
};
|
|
99
|
+
});
|
|
100
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { type ConsoleBindingScope, type ConsolePluginPermissionInfo, type ConsoleRolePluginPermission } from '@zhin.js/console-protocol';
|
|
2
|
+
import type { ConsoleAccessProvider, ConsoleAuditEntry, ConsoleRequestMeta, ConsoleUserPrincipal } from '@zhin.js/host-http';
|
|
3
|
+
import type { DatabaseHostModel } from '@zhin.js/plugin-runtime';
|
|
4
|
+
import { LoginRateLimiter } from './rate-limit.js';
|
|
5
|
+
export type IamErrorCode = 'invalid_input' | 'not_found' | 'conflict' | 'forbidden' | 'last_owner' | 'unauthorized' | 'rate_limited' | 'unavailable';
|
|
6
|
+
export declare class IamError extends Error {
|
|
7
|
+
readonly code: IamErrorCode;
|
|
8
|
+
readonly retryAfterMs?: number | undefined;
|
|
9
|
+
readonly status: number;
|
|
10
|
+
constructor(code: IamErrorCode, message: string, retryAfterMs?: number | undefined);
|
|
11
|
+
}
|
|
12
|
+
/** Minimal database surface: the process DatabaseHost, or one the CLI opens itself. */
|
|
13
|
+
export interface IamDatabase {
|
|
14
|
+
readonly started: boolean;
|
|
15
|
+
readonly models: {
|
|
16
|
+
get(name: string): DatabaseHostModel | undefined;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export interface ConsoleUserView {
|
|
20
|
+
readonly id: string;
|
|
21
|
+
readonly username: string;
|
|
22
|
+
readonly displayName: string;
|
|
23
|
+
readonly disabled: boolean;
|
|
24
|
+
readonly createdAt: number;
|
|
25
|
+
readonly updatedAt: number;
|
|
26
|
+
readonly passwordChangedAt: number;
|
|
27
|
+
readonly bindings: readonly ConsoleBindingView[];
|
|
28
|
+
}
|
|
29
|
+
export interface ConsoleBindingView {
|
|
30
|
+
readonly id: string;
|
|
31
|
+
readonly roleId: string;
|
|
32
|
+
readonly role: string;
|
|
33
|
+
readonly scope: ConsoleBindingScope;
|
|
34
|
+
}
|
|
35
|
+
export interface ConsoleRoleView {
|
|
36
|
+
readonly id: string;
|
|
37
|
+
readonly name: string;
|
|
38
|
+
readonly description: string;
|
|
39
|
+
readonly builtin: boolean;
|
|
40
|
+
readonly permissions: readonly string[];
|
|
41
|
+
/**
|
|
42
|
+
* Plugin permissions this role holds right now: by level (`plugin.perms.<level>`, expanded
|
|
43
|
+
* over the loaded plugins' catalog) or listed explicitly. Only on `listRoles` / role writes.
|
|
44
|
+
*/
|
|
45
|
+
readonly pluginPermissions?: readonly ConsoleRolePluginPermission[];
|
|
46
|
+
}
|
|
47
|
+
export interface ConsoleTokenView {
|
|
48
|
+
readonly id: string;
|
|
49
|
+
readonly userId: string;
|
|
50
|
+
readonly name: string;
|
|
51
|
+
readonly permissions: readonly string[] | null;
|
|
52
|
+
readonly expiresAt: number | null;
|
|
53
|
+
readonly lastUsedAt: number | null;
|
|
54
|
+
readonly createdAt: number;
|
|
55
|
+
}
|
|
56
|
+
export interface ConsoleAuditView {
|
|
57
|
+
readonly id: string;
|
|
58
|
+
readonly at: number;
|
|
59
|
+
readonly actor: string;
|
|
60
|
+
readonly action: string;
|
|
61
|
+
readonly target: string | null;
|
|
62
|
+
readonly outcome: string;
|
|
63
|
+
readonly detail: unknown;
|
|
64
|
+
readonly ip: string | null;
|
|
65
|
+
}
|
|
66
|
+
export interface BindingInput {
|
|
67
|
+
readonly role: string;
|
|
68
|
+
readonly scopeKind?: string;
|
|
69
|
+
readonly scopeKey?: string | null;
|
|
70
|
+
}
|
|
71
|
+
export interface ConsoleIamServiceOptions {
|
|
72
|
+
readonly database: IamDatabase;
|
|
73
|
+
/** Marker telling a restarting Host that setup is done before the database is up. */
|
|
74
|
+
readonly projectRoot?: string;
|
|
75
|
+
readonly now?: () => number;
|
|
76
|
+
readonly sessionIdleMs?: number;
|
|
77
|
+
readonly sessionMaxMs?: number;
|
|
78
|
+
readonly auditRetentionMs?: number;
|
|
79
|
+
readonly rateLimiter?: LoginRateLimiter;
|
|
80
|
+
readonly onError?: (error: unknown) => void;
|
|
81
|
+
/**
|
|
82
|
+
* Plugin permission catalog of the loaded plugins (ids and declared-or-inferred levels).
|
|
83
|
+
* Without it, levels are inferred from the permission suffix.
|
|
84
|
+
*/
|
|
85
|
+
readonly pluginPermissions?: () => readonly ConsolePluginPermissionInfo[];
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* What is wrong with a new account's username / password, or null — the rules
|
|
89
|
+
* {@link ConsoleIamService.createUser} / `createOwner` enforce, for callers that must check before
|
|
90
|
+
* changing anything else (`zhin console init` switches the database first).
|
|
91
|
+
*/
|
|
92
|
+
export declare function accountInputProblem(input: Readonly<{
|
|
93
|
+
username: unknown;
|
|
94
|
+
password: unknown;
|
|
95
|
+
}>): string | null;
|
|
96
|
+
/** Why a user's credentials were revoked (sessions always; API tokens on disable/delete). */
|
|
97
|
+
export type ConsoleCredentialRevocationReason = 'password' | 'disabled' | 'deleted' | 'bindings';
|
|
98
|
+
export interface ConsoleCredentialRevocation {
|
|
99
|
+
readonly userId: string;
|
|
100
|
+
readonly reason: ConsoleCredentialRevocationReason;
|
|
101
|
+
}
|
|
102
|
+
/** DB-backed users, roles, sessions, API tokens and audit (ADR 0049 §2, §5, §6). */
|
|
103
|
+
export declare class ConsoleIamService implements ConsoleAccessProvider {
|
|
104
|
+
#private;
|
|
105
|
+
constructor(options: ConsoleIamServiceOptions);
|
|
106
|
+
get ready(): boolean;
|
|
107
|
+
/** Seeds built-in roles and loads the owner state; call after the database started. */
|
|
108
|
+
start(options?: Readonly<{
|
|
109
|
+
timers?: boolean;
|
|
110
|
+
}>): Promise<void>;
|
|
111
|
+
/**
|
|
112
|
+
* Notified after a user's sessions were revoked or their grants changed, so long-lived
|
|
113
|
+
* connections (terminal, SSE) authenticated earlier can be closed. Returns an unsubscriber.
|
|
114
|
+
*/
|
|
115
|
+
onCredentialsRevoked(listener: (event: ConsoleCredentialRevocation) => void): () => void;
|
|
116
|
+
stop(): void;
|
|
117
|
+
initialized(): boolean;
|
|
118
|
+
refreshOwnerState(): Promise<boolean>;
|
|
119
|
+
authenticate(token: string, meta?: ConsoleRequestMeta): Promise<ConsoleUserPrincipal | null>;
|
|
120
|
+
audit(entry: ConsoleAuditEntry): void;
|
|
121
|
+
login(usernameInput: unknown, password: unknown, meta?: ConsoleRequestMeta): Promise<{
|
|
122
|
+
token: string;
|
|
123
|
+
expiresAt: number;
|
|
124
|
+
user: ConsoleUserView;
|
|
125
|
+
}>;
|
|
126
|
+
logout(principal: ConsoleUserPrincipal): Promise<void>;
|
|
127
|
+
changePassword(principal: ConsoleUserPrincipal, current: unknown, next: unknown, meta?: ConsoleRequestMeta): Promise<{
|
|
128
|
+
token?: string;
|
|
129
|
+
expiresAt?: number;
|
|
130
|
+
}>;
|
|
131
|
+
listUsers(): Promise<readonly ConsoleUserView[]>;
|
|
132
|
+
getUser(id: string): Promise<ConsoleUserView | null>;
|
|
133
|
+
findUser(username: string): Promise<ConsoleUserView | null>;
|
|
134
|
+
createUser(input: Readonly<{
|
|
135
|
+
username: unknown;
|
|
136
|
+
password: unknown;
|
|
137
|
+
displayName?: unknown;
|
|
138
|
+
bindings?: readonly BindingInput[];
|
|
139
|
+
}>): Promise<ConsoleUserView>;
|
|
140
|
+
/** First owner (setup). Conflict once an enabled global owner exists. */
|
|
141
|
+
createOwner(input: Readonly<{
|
|
142
|
+
username: unknown;
|
|
143
|
+
password: unknown;
|
|
144
|
+
displayName?: unknown;
|
|
145
|
+
}>): Promise<ConsoleUserView>;
|
|
146
|
+
/** Issues a session for a user (setup returns one for the new owner). */
|
|
147
|
+
issueSession(userId: string, meta?: ConsoleRequestMeta): Promise<{
|
|
148
|
+
token: string;
|
|
149
|
+
expiresAt: number;
|
|
150
|
+
}>;
|
|
151
|
+
updateUser(id: string, patch: Readonly<{
|
|
152
|
+
displayName?: unknown;
|
|
153
|
+
disabled?: unknown;
|
|
154
|
+
}>): Promise<ConsoleUserView>;
|
|
155
|
+
setDisabled(id: string, disabled: boolean): Promise<ConsoleUserView>;
|
|
156
|
+
deleteUser(id: string): Promise<void>;
|
|
157
|
+
resetPassword(id: string, password: unknown): Promise<void>;
|
|
158
|
+
listRoles(): Promise<readonly ConsoleRoleView[]>;
|
|
159
|
+
/** The loaded plugins' permission catalog (what the Console's role editor offers). */
|
|
160
|
+
pluginPermissionCatalog(): readonly ConsolePluginPermissionInfo[];
|
|
161
|
+
/**
|
|
162
|
+
* Plugin permission ids in `permissions` that no loaded plugin uses (accepted, but they grant
|
|
163
|
+
* nothing until a plugin uses them); for the warnings of role and token writes.
|
|
164
|
+
*/
|
|
165
|
+
unknownPluginPermissions(permissions: readonly string[]): readonly string[];
|
|
166
|
+
createRole(input: Readonly<{
|
|
167
|
+
name: unknown;
|
|
168
|
+
description?: unknown;
|
|
169
|
+
permissions: unknown;
|
|
170
|
+
}>): Promise<ConsoleRoleView>;
|
|
171
|
+
updateRole(id: string, patch: Readonly<{
|
|
172
|
+
description?: unknown;
|
|
173
|
+
permissions?: unknown;
|
|
174
|
+
}>): Promise<ConsoleRoleView>;
|
|
175
|
+
deleteRole(id: string): Promise<void>;
|
|
176
|
+
/** Replaces all bindings of a user. */
|
|
177
|
+
setBindings(userId: string, inputs: readonly BindingInput[]): Promise<ConsoleUserView>;
|
|
178
|
+
listTokens(userId?: string): Promise<readonly ConsoleTokenView[]>;
|
|
179
|
+
/** Returns the plaintext once; only its SHA-256 is stored. */
|
|
180
|
+
createToken(userId: string, input: Readonly<{
|
|
181
|
+
name: unknown;
|
|
182
|
+
permissions?: unknown;
|
|
183
|
+
expiresAt?: unknown;
|
|
184
|
+
}>): Promise<{
|
|
185
|
+
token: string;
|
|
186
|
+
record: ConsoleTokenView;
|
|
187
|
+
}>;
|
|
188
|
+
/** `ownerUserId` restricts the revoke to that user's own tokens (self-service). */
|
|
189
|
+
revokeToken(id: string, ownerUserId?: string): Promise<void>;
|
|
190
|
+
listAudit(filter?: Readonly<{
|
|
191
|
+
actor?: unknown;
|
|
192
|
+
action?: unknown;
|
|
193
|
+
since?: unknown;
|
|
194
|
+
until?: unknown;
|
|
195
|
+
limit?: unknown;
|
|
196
|
+
before?: unknown;
|
|
197
|
+
}>): Promise<{
|
|
198
|
+
entries: readonly ConsoleAuditView[];
|
|
199
|
+
next: number | null;
|
|
200
|
+
}>;
|
|
201
|
+
/** Daily: audit older than the retention window and expired sessions. */
|
|
202
|
+
cleanup(): Promise<void>;
|
|
203
|
+
}
|