@zhin.js/gateway 0.0.0-stage → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +60 -2
  3. package/lib/config/ai-config-loader.d.ts +22 -0
  4. package/lib/config/ai-config-loader.js +96 -0
  5. package/lib/config/configuration-chat-policy.d.ts +57 -0
  6. package/lib/config/configuration-chat-policy.js +423 -0
  7. package/lib/config/configuration-patch.d.ts +16 -0
  8. package/lib/config/configuration-patch.js +105 -0
  9. package/lib/config/configuration-path.d.ts +6 -0
  10. package/lib/config/configuration-path.js +40 -0
  11. package/lib/config/configuration-projection.d.ts +6 -0
  12. package/lib/config/configuration-projection.js +28 -0
  13. package/lib/config/configuration-redaction.d.ts +24 -0
  14. package/lib/config/configuration-redaction.js +128 -0
  15. package/lib/config/configuration.d.ts +81 -0
  16. package/lib/config/configuration.js +686 -0
  17. package/lib/config/console-schema.d.ts +11 -0
  18. package/lib/config/console-schema.js +222 -0
  19. package/lib/config/environment-files.d.ts +2 -0
  20. package/lib/config/environment-files.js +21 -0
  21. package/lib/config/plugin-package-map.d.ts +2 -0
  22. package/lib/config/plugin-package-map.js +14 -0
  23. package/lib/config/plugin-schema-catalog.d.ts +22 -0
  24. package/lib/config/plugin-schema-catalog.js +262 -0
  25. package/lib/config.d.ts +12 -0
  26. package/lib/config.js +12 -0
  27. package/lib/database/config.d.ts +22 -0
  28. package/lib/database/config.js +159 -0
  29. package/lib/database/index.d.ts +2 -0
  30. package/lib/database/index.js +2 -0
  31. package/lib/database/project-database.d.ts +31 -0
  32. package/lib/database/project-database.js +130 -0
  33. package/lib/database.d.ts +2 -0
  34. package/lib/database.js +2 -0
  35. package/lib/http/console-rpc.d.ts +19 -0
  36. package/lib/http/console-rpc.js +51 -0
  37. package/lib/http/gateway-console.d.ts +63 -0
  38. package/lib/http/gateway-console.js +309 -0
  39. package/lib/http/gateway-events.d.ts +35 -0
  40. package/lib/http/gateway-events.js +172 -0
  41. package/lib/http/gateway-http-server.d.ts +95 -0
  42. package/lib/http/gateway-http-server.js +518 -0
  43. package/lib/http/local-principal.d.ts +2 -0
  44. package/lib/http/local-principal.js +16 -0
  45. package/lib/http/run-gateway.d.ts +43 -0
  46. package/lib/http/run-gateway.js +268 -0
  47. package/lib/http/start-gateway.d.ts +114 -0
  48. package/lib/http/start-gateway.js +220 -0
  49. package/lib/http-response.d.ts +6 -0
  50. package/lib/http-response.js +25 -0
  51. package/lib/http.d.ts +6 -0
  52. package/lib/http.js +6 -0
  53. package/lib/iam/crypto.d.ts +12 -0
  54. package/lib/iam/crypto.js +58 -0
  55. package/lib/iam/index.d.ts +8 -0
  56. package/lib/iam/index.js +8 -0
  57. package/lib/iam/install.d.ts +36 -0
  58. package/lib/iam/install.js +74 -0
  59. package/lib/iam/local-admin.d.ts +27 -0
  60. package/lib/iam/local-admin.js +102 -0
  61. package/lib/iam/rate-limit.d.ts +15 -0
  62. package/lib/iam/rate-limit.js +52 -0
  63. package/lib/iam/routes.d.ts +23 -0
  64. package/lib/iam/routes.js +157 -0
  65. package/lib/iam/rpc.d.ts +14 -0
  66. package/lib/iam/rpc.js +100 -0
  67. package/lib/iam/service.d.ts +203 -0
  68. package/lib/iam/service.js +784 -0
  69. package/lib/iam/setup-service.d.ts +83 -0
  70. package/lib/iam/setup-service.js +206 -0
  71. package/lib/iam/tables.d.ts +20 -0
  72. package/lib/iam/tables.js +82 -0
  73. package/lib/iam.d.ts +2 -0
  74. package/lib/iam.js +2 -0
  75. package/lib/index.d.ts +22 -0
  76. package/lib/index.js +22 -0
  77. package/lib/local/client.d.ts +71 -0
  78. package/lib/local/client.js +296 -0
  79. package/lib/local/handler.d.ts +30 -0
  80. package/lib/local/handler.js +86 -0
  81. package/lib/local/local-socket.d.ts +55 -0
  82. package/lib/local/local-socket.js +232 -0
  83. package/lib/local/paths.d.ts +34 -0
  84. package/lib/local/paths.js +58 -0
  85. package/lib/local/protocol.d.ts +140 -0
  86. package/lib/local/protocol.js +90 -0
  87. package/lib/local/state-file.d.ts +8 -0
  88. package/lib/local/state-file.js +45 -0
  89. package/lib/local.d.ts +10 -0
  90. package/lib/local.js +10 -0
  91. package/lib/onboarding/allowlist.d.ts +54 -0
  92. package/lib/onboarding/allowlist.js +106 -0
  93. package/lib/onboarding/errors.d.ts +7 -0
  94. package/lib/onboarding/errors.js +17 -0
  95. package/lib/onboarding/index.d.ts +6 -0
  96. package/lib/onboarding/index.js +6 -0
  97. package/lib/onboarding/install.d.ts +99 -0
  98. package/lib/onboarding/install.js +132 -0
  99. package/lib/onboarding/package-manager.d.ts +47 -0
  100. package/lib/onboarding/package-manager.js +179 -0
  101. package/lib/onboarding/service.d.ts +53 -0
  102. package/lib/onboarding/service.js +144 -0
  103. package/lib/onboarding/state-store.d.ts +26 -0
  104. package/lib/onboarding/state-store.js +97 -0
  105. package/lib/onboarding.d.ts +2 -0
  106. package/lib/onboarding.js +2 -0
  107. package/lib/packages/authorization.d.ts +23 -0
  108. package/lib/packages/authorization.js +109 -0
  109. package/lib/packages/errors.d.ts +11 -0
  110. package/lib/packages/errors.js +26 -0
  111. package/lib/packages/index.d.ts +5 -0
  112. package/lib/packages/index.js +5 -0
  113. package/lib/packages/rpc.d.ts +30 -0
  114. package/lib/packages/rpc.js +155 -0
  115. package/lib/packages/service.d.ts +121 -0
  116. package/lib/packages/service.js +441 -0
  117. package/lib/packages/snapshot.d.ts +14 -0
  118. package/lib/packages/snapshot.js +73 -0
  119. package/lib/packages.d.ts +2 -0
  120. package/lib/packages.js +2 -0
  121. package/lib/plugin-management/activity.d.ts +6 -0
  122. package/lib/plugin-management/activity.js +14 -0
  123. package/lib/plugin-management/config-explanation.d.ts +6 -0
  124. package/lib/plugin-management/config-explanation.js +113 -0
  125. package/lib/plugin-management/use-cases.d.ts +15 -0
  126. package/lib/plugin-management/use-cases.js +120 -0
  127. package/lib/plugin-management/workflow.d.ts +92 -0
  128. package/lib/plugin-management/workflow.js +772 -0
  129. package/lib/plugin-management.d.ts +5 -0
  130. package/lib/plugin-management.js +5 -0
  131. package/lib/project/config-file.d.ts +6 -0
  132. package/lib/project/config-file.js +58 -0
  133. package/lib/project/environment.d.ts +13 -0
  134. package/lib/project/environment.js +52 -0
  135. package/lib/project/instance-declarations.d.ts +55 -0
  136. package/lib/project/instance-declarations.js +542 -0
  137. package/lib/project/legacy-config.d.ts +25 -0
  138. package/lib/project/legacy-config.js +70 -0
  139. package/lib/project/node-requirements.d.ts +7 -0
  140. package/lib/project/node-requirements.js +20 -0
  141. package/lib/project/tsx-module-loader.d.ts +7 -0
  142. package/lib/project/tsx-module-loader.js +29 -0
  143. package/lib/project.d.ts +7 -0
  144. package/lib/project.js +7 -0
  145. package/lib/server.d.ts +11 -0
  146. package/lib/server.js +11 -0
  147. package/lib/supervisor/instance-logs.d.ts +39 -0
  148. package/lib/supervisor/instance-logs.js +152 -0
  149. package/lib/supervisor/instance-supervisor.d.ts +140 -0
  150. package/lib/supervisor/instance-supervisor.js +408 -0
  151. package/lib/supervisor/run-instance.d.ts +35 -0
  152. package/lib/supervisor/run-instance.js +161 -0
  153. package/lib/supervisor/terminal-state.d.ts +30 -0
  154. package/lib/supervisor/terminal-state.js +57 -0
  155. package/lib/supervisor.d.ts +4 -0
  156. package/lib/supervisor.js +4 -0
  157. package/lib/terminal/backends.d.ts +38 -0
  158. package/lib/terminal/backends.js +193 -0
  159. package/lib/terminal/config.d.ts +63 -0
  160. package/lib/terminal/config.js +237 -0
  161. package/lib/terminal/host.d.ts +26 -0
  162. package/lib/terminal/host.js +76 -0
  163. package/lib/terminal/index.d.ts +60 -0
  164. package/lib/terminal/index.js +135 -0
  165. package/lib/terminal/modules.d.ts +87 -0
  166. package/lib/terminal/modules.js +44 -0
  167. package/lib/terminal/service.d.ts +101 -0
  168. package/lib/terminal/service.js +455 -0
  169. package/lib/terminal/tickets.d.ts +46 -0
  170. package/lib/terminal/tickets.js +76 -0
  171. package/lib/terminal.d.ts +3 -0
  172. package/lib/terminal.js +3 -0
  173. package/lib/usage/aggregate.d.ts +87 -0
  174. package/lib/usage/aggregate.js +212 -0
  175. package/lib/usage.d.ts +2 -0
  176. package/lib/usage.js +2 -0
  177. package/package.json +116 -3
@@ -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';
@@ -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
+ }
@@ -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
+ }
@@ -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[];