@zhin.js/gateway 0.0.1-bootstrap.0 → 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 +61 -0
  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 +117 -4
@@ -0,0 +1,784 @@
1
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { CONSOLE_BUILTIN_ROLE_NAMES, CONSOLE_BUILTIN_ROLES, CONSOLE_PLUGIN_LEVEL_PERMISSIONS, consolePluginPermissionLevel, isConsolePermission, isConsolePluginPermission, parseConsoleBindingScope, permissionListHoldsPluginPermission, pluginPermissionLevelResolver, } from '@zhin.js/console-protocol';
4
+ import { generateToken, hashPassword, newId, passwordProblem, sha256, verifyAgainstDummy, verifyPassword, } from './crypto.js';
5
+ import { isDatabaseBusyError } from '../database/project-database.js';
6
+ import { LoginRateLimiter } from './rate-limit.js';
7
+ import { IAM_TABLES } from './tables.js';
8
+ const STATUS = {
9
+ invalid_input: 400, not_found: 404, conflict: 409, forbidden: 403, last_owner: 409,
10
+ unauthorized: 401, rate_limited: 429, unavailable: 503,
11
+ };
12
+ export class IamError extends Error {
13
+ code;
14
+ retryAfterMs;
15
+ status;
16
+ constructor(code, message, retryAfterMs) {
17
+ super(message);
18
+ this.code = code;
19
+ this.retryAfterMs = retryAfterMs;
20
+ this.name = 'IamError';
21
+ this.status = STATUS[code];
22
+ }
23
+ }
24
+ const USERNAME = /^[a-z0-9][a-z0-9._-]{1,63}$/u;
25
+ /**
26
+ * What is wrong with a new account's username / password, or null — the rules
27
+ * {@link ConsoleIamService.createUser} / `createOwner` enforce, for callers that must check before
28
+ * changing anything else (`zhin console init` switches the database first).
29
+ */
30
+ export function accountInputProblem(input) {
31
+ const username = typeof input.username === 'string' ? input.username.trim().toLowerCase() : '';
32
+ if (!USERNAME.test(username))
33
+ return '用户名需为 2-64 位小写字母、数字或 . _ -';
34
+ return passwordProblem(input.password);
35
+ }
36
+ const ROLE_NAME = /^[a-z][a-z0-9_-]{1,63}$/u;
37
+ const DAY = 24 * 60 * 60_000;
38
+ const builtinRoleId = (name) => `builtin:${name}`;
39
+ const OWNER_ROLE_ID = builtinRoleId('owner');
40
+ /** DB-backed users, roles, sessions, API tokens and audit (ADR 0049 §2, §5, §6). */
41
+ export class ConsoleIamService {
42
+ #db;
43
+ #marker;
44
+ #now;
45
+ #idle;
46
+ #max;
47
+ #retention;
48
+ #limiter;
49
+ #onError;
50
+ #pluginPermissions;
51
+ #ready = false;
52
+ #initialized;
53
+ #cleanupTimer;
54
+ #refreshTimer;
55
+ #mutation = Promise.resolve();
56
+ #revocationListeners = new Set();
57
+ constructor(options) {
58
+ this.#db = options.database;
59
+ this.#marker = options.projectRoot ? join(options.projectRoot, '.zhin', 'console-iam.initialized') : undefined;
60
+ this.#now = options.now ?? Date.now;
61
+ this.#idle = options.sessionIdleMs ?? 12 * 60 * 60_000;
62
+ this.#max = options.sessionMaxMs ?? 30 * DAY;
63
+ this.#retention = options.auditRetentionMs ?? 180 * DAY;
64
+ this.#limiter = options.rateLimiter ?? new LoginRateLimiter({ now: this.#now });
65
+ this.#onError = options.onError ?? (() => undefined);
66
+ this.#pluginPermissions = options.pluginPermissions ?? (() => []);
67
+ }
68
+ get ready() {
69
+ return this.#ready;
70
+ }
71
+ /** Seeds built-in roles and loads the owner state; call after the database started. */
72
+ async start(options = {}) {
73
+ const roles = this.#model('roles');
74
+ for (const name of CONSOLE_BUILTIN_ROLE_NAMES) {
75
+ const definition = CONSOLE_BUILTIN_ROLES[name];
76
+ const row = { name, description: definition.description, builtin: 1, permissions: JSON.stringify(definition.permissions) };
77
+ const [existing] = await roles.select('id').where({ id: builtinRoleId(name) });
78
+ if (existing)
79
+ await roles.update(row).where({ id: builtinRoleId(name) });
80
+ else
81
+ await roles.insert({ id: builtinRoleId(name), ...row });
82
+ }
83
+ this.#ready = true;
84
+ await this.refreshOwnerState();
85
+ if (options.timers !== false) {
86
+ void this.cleanup().catch(this.#onError);
87
+ this.#cleanupTimer = setInterval(() => void this.cleanup().catch(this.#onError), DAY);
88
+ this.#cleanupTimer.unref?.();
89
+ // Offline CLI edits (rescue) become visible without a restart.
90
+ this.#refreshTimer = setInterval(() => void this.refreshOwnerState().catch(this.#onError), 30_000);
91
+ this.#refreshTimer.unref?.();
92
+ }
93
+ }
94
+ /**
95
+ * Notified after a user's sessions were revoked or their grants changed, so long-lived
96
+ * connections (terminal, SSE) authenticated earlier can be closed. Returns an unsubscriber.
97
+ */
98
+ onCredentialsRevoked(listener) {
99
+ this.#revocationListeners.add(listener);
100
+ return () => { this.#revocationListeners.delete(listener); };
101
+ }
102
+ #emitRevocation(userId, reason) {
103
+ const event = Object.freeze({ userId, reason });
104
+ for (const listener of [...this.#revocationListeners]) {
105
+ try {
106
+ listener(event);
107
+ }
108
+ catch (error) {
109
+ this.#onError(error);
110
+ }
111
+ }
112
+ }
113
+ stop() {
114
+ clearInterval(this.#cleanupTimer);
115
+ clearInterval(this.#refreshTimer);
116
+ }
117
+ initialized() {
118
+ if (this.#initialized === true)
119
+ return true;
120
+ // Database not up yet, or another process (`zhin console init` beside a running gateway)
121
+ // completed setup since the last refresh: fail closed on the setup marker and re-read the
122
+ // owner state now instead of keeping the Host open until the next periodic refresh.
123
+ const marked = this.#marker !== undefined && existsSync(this.#marker);
124
+ if (this.#initialized === undefined)
125
+ return marked;
126
+ if (marked && this.#ready)
127
+ this.#refreshSoon();
128
+ return marked;
129
+ }
130
+ #pendingRefresh;
131
+ #refreshSoon() {
132
+ if (this.#pendingRefresh !== undefined)
133
+ return;
134
+ this.#pendingRefresh = this.refreshOwnerState()
135
+ .catch(this.#onError)
136
+ .finally(() => { this.#pendingRefresh = undefined; });
137
+ }
138
+ async refreshOwnerState() {
139
+ const owners = await this.#activeOwnerIds();
140
+ this.#initialized = owners.size > 0;
141
+ if (this.#marker) {
142
+ try {
143
+ if (this.#initialized) {
144
+ mkdirSync(dirname(this.#marker), { recursive: true });
145
+ writeFileSync(this.#marker, `${this.#now()}\n`);
146
+ }
147
+ else if (existsSync(this.#marker)) {
148
+ rmSync(this.#marker, { force: true });
149
+ }
150
+ }
151
+ catch (error) {
152
+ this.#onError(error);
153
+ }
154
+ }
155
+ return this.#initialized;
156
+ }
157
+ // ------------------------------------------------------------------ authentication
158
+ async authenticate(token, meta = {}) {
159
+ if (!this.#ready || !token || token.length > 256)
160
+ return null;
161
+ const hash = sha256(token);
162
+ const now = this.#now();
163
+ const [session] = await this.#model('sessions').select('id', 'user_id', 'created_at', 'expires_at', 'last_seen_at').where({ id: hash });
164
+ if (session) {
165
+ if (Number(session.expires_at) <= now) {
166
+ await this.#touch(() => this.#model('sessions').delete().where({ id: hash }));
167
+ return null;
168
+ }
169
+ const user = await this.#enabledUser(String(session.user_id));
170
+ if (!user)
171
+ return null;
172
+ if (now - Number(session.last_seen_at) > 60_000) {
173
+ const expiresAt = Math.min(Number(session.created_at) + this.#max, now + this.#idle);
174
+ await this.#touch(() => this.#model('sessions').update({ last_seen_at: now, expires_at: expiresAt }).where({ id: hash }));
175
+ }
176
+ return this.#principal(user, 'user', hash, null);
177
+ }
178
+ const [apiToken] = await this.#model('tokens').select('id', 'user_id', 'permissions', 'expires_at', 'last_used_at').where({ token_hash: hash });
179
+ if (!apiToken)
180
+ return null;
181
+ if (apiToken.expires_at !== null && apiToken.expires_at !== undefined && Number(apiToken.expires_at) <= now)
182
+ return null;
183
+ const user = await this.#enabledUser(String(apiToken.user_id));
184
+ if (!user)
185
+ return null;
186
+ if (apiToken.last_used_at === null || apiToken.last_used_at === undefined || now - Number(apiToken.last_used_at) > 60_000) {
187
+ await this.#touch(() => this.#model('tokens').update({ last_used_at: now }).where({ id: apiToken.id }));
188
+ }
189
+ void meta;
190
+ return this.#principal(user, 'api-token', String(apiToken.id), parseList(apiToken.permissions));
191
+ }
192
+ audit(entry) {
193
+ if (!this.#ready)
194
+ return;
195
+ void this.#model('audit').insert({
196
+ id: newId(),
197
+ at: this.#now(),
198
+ actor: entry.actor.slice(0, 128),
199
+ action: entry.action.slice(0, 256),
200
+ target: entry.target?.slice(0, 256) ?? null,
201
+ outcome: entry.outcome,
202
+ detail: entry.detail ? JSON.stringify(entry.detail).slice(0, 4096) : null,
203
+ ip: entry.ip?.slice(0, 64) ?? null,
204
+ }).catch(this.#onError);
205
+ }
206
+ async login(usernameInput, password, meta = {}) {
207
+ this.#requireReady();
208
+ const username = typeof usernameInput === 'string' ? usernameInput.trim().toLowerCase() : '';
209
+ const keys = [`ip:${meta.ip ?? 'unknown'}`, `user:${username}`];
210
+ const wait = this.#limiter.retryAfter(keys);
211
+ if (wait > 0) {
212
+ this.audit({ actor: `login:${username.slice(0, 64)}`, action: 'auth.login', outcome: 'denied', detail: { reason: 'rate_limited' }, ...(meta.ip ? { ip: meta.ip } : {}) });
213
+ throw new IamError('rate_limited', '登录尝试过多,请稍后再试', wait);
214
+ }
215
+ const secret = typeof password === 'string' ? password : '';
216
+ const [row] = USERNAME.test(username) && secret
217
+ ? await this.#model('users').select('id', 'password_hash', 'disabled').where({ username })
218
+ : [];
219
+ const ok = row && Number(row.disabled) !== 1
220
+ ? await verifyPassword(secret, String(row.password_hash))
221
+ : await verifyAgainstDummy(secret || 'x');
222
+ if (!ok || !row) {
223
+ this.#limiter.fail(keys);
224
+ this.audit({ actor: row ? `user:${String(row.id)}` : `login:${username.slice(0, 64)}`, action: 'auth.login', outcome: 'denied', ...(meta.ip ? { ip: meta.ip } : {}) });
225
+ throw new IamError('unauthorized', '用户名或密码错误');
226
+ }
227
+ this.#limiter.succeed([`user:${username}`]);
228
+ const issued = await this.#issueSession(String(row.id), meta);
229
+ this.audit({ actor: `user:${String(row.id)}`, action: 'auth.login', outcome: 'ok', ...(meta.ip ? { ip: meta.ip } : {}) });
230
+ return { ...issued, user: (await this.getUser(String(row.id))) };
231
+ }
232
+ async #issueSession(userId, meta) {
233
+ const token = generateToken();
234
+ const now = this.#now();
235
+ const expiresAt = Math.min(now + this.#idle, now + this.#max);
236
+ await this.#model('sessions').insert({
237
+ id: sha256(token), user_id: userId, created_at: now, expires_at: expiresAt, last_seen_at: now,
238
+ ip: meta.ip?.slice(0, 64) ?? null, user_agent: meta.userAgent?.slice(0, 256) ?? null,
239
+ });
240
+ return { token, expiresAt };
241
+ }
242
+ async logout(principal) {
243
+ if (principal.kind === 'user')
244
+ await this.#model('sessions').delete().where({ id: principal.credentialId });
245
+ this.audit({ actor: actorOf(principal), action: 'auth.logout', outcome: 'ok' });
246
+ }
247
+ async changePassword(principal, current, next, meta = {}) {
248
+ const [row] = await this.#model('users').select('password_hash').where({ id: principal.userId });
249
+ const keys = [`ip:${meta.ip ?? 'unknown'}`, `user:${principal.username}`];
250
+ if (this.#limiter.retryAfter(keys) > 0)
251
+ throw new IamError('rate_limited', '尝试过多,请稍后再试', this.#limiter.retryAfter(keys));
252
+ if (!row || typeof current !== 'string' || !await verifyPassword(current, String(row.password_hash))) {
253
+ this.#limiter.fail(keys);
254
+ this.audit({ actor: actorOf(principal), action: 'auth.password', outcome: 'denied' });
255
+ throw new IamError('unauthorized', '当前密码错误');
256
+ }
257
+ await this.#setPassword(principal.userId, next);
258
+ this.audit({ actor: actorOf(principal), action: 'auth.password', outcome: 'ok' });
259
+ // Every session was revoked: hand the caller a fresh one when it used a session.
260
+ return principal.kind === 'user' ? this.#issueSession(principal.userId, meta) : {};
261
+ }
262
+ // ------------------------------------------------------------------ users
263
+ async listUsers() {
264
+ this.#requireReady();
265
+ const rows = await this.#model('users').select('id', 'username', 'display_name', 'disabled', 'created_at', 'updated_at', 'password_changed_at').orderBy('username', 'ASC');
266
+ const bindings = await this.#allBindings();
267
+ return rows.map(row => userView(row, bindings.filter(binding => binding.userId === String(row.id))));
268
+ }
269
+ async getUser(id) {
270
+ const [row] = await this.#model('users').select('id', 'username', 'display_name', 'disabled', 'created_at', 'updated_at', 'password_changed_at').where({ id });
271
+ if (!row)
272
+ return null;
273
+ const bindings = (await this.#allBindings()).filter(binding => binding.userId === id);
274
+ return userView(row, bindings);
275
+ }
276
+ async findUser(username) {
277
+ const [row] = await this.#model('users').select('id').where({ username: username.trim().toLowerCase() });
278
+ return row ? this.getUser(String(row.id)) : null;
279
+ }
280
+ createUser(input) {
281
+ return this.#serialize(() => this.#createUser(input));
282
+ }
283
+ async #createUser(input) {
284
+ {
285
+ this.#requireReady();
286
+ const username = typeof input.username === 'string' ? input.username.trim().toLowerCase() : '';
287
+ if (!USERNAME.test(username))
288
+ throw new IamError('invalid_input', '用户名需为 2-64 位小写字母、数字或 . _ -');
289
+ const problem = passwordProblem(input.password);
290
+ if (problem)
291
+ throw new IamError('invalid_input', problem);
292
+ const displayName = typeof input.displayName === 'string' && input.displayName.trim() ? input.displayName.trim().slice(0, 128) : username;
293
+ const [existing] = await this.#model('users').select('id').where({ username });
294
+ if (existing)
295
+ throw new IamError('conflict', '用户名已存在');
296
+ const bindings = await this.#resolveBindings(input.bindings ?? []);
297
+ const now = this.#now();
298
+ const id = newId();
299
+ await this.#model('users').insert({
300
+ id, username, display_name: displayName, password_hash: await hashPassword(input.password),
301
+ disabled: 0, password_changed_at: now, created_at: now, updated_at: now,
302
+ });
303
+ for (const binding of bindings)
304
+ await this.#model('bindings').insert({ id: newId(), user_id: id, ...binding });
305
+ await this.refreshOwnerState();
306
+ return (await this.getUser(id));
307
+ }
308
+ }
309
+ /** First owner (setup). Conflict once an enabled global owner exists. */
310
+ createOwner(input) {
311
+ return this.#serialize(async () => {
312
+ this.#requireReady();
313
+ if ((await this.#activeOwnerIds()).size > 0)
314
+ throw new IamError('conflict', '已初始化:所有者已存在');
315
+ return this.#createUser({ ...input, bindings: [{ role: 'owner', scopeKind: 'global' }] });
316
+ });
317
+ }
318
+ /** Issues a session for a user (setup returns one for the new owner). */
319
+ issueSession(userId, meta = {}) {
320
+ this.#requireReady();
321
+ return this.#issueSession(userId, meta);
322
+ }
323
+ updateUser(id, patch) {
324
+ return this.#serialize(async () => {
325
+ const user = await this.#requireUser(id);
326
+ const row = { updated_at: this.#now() };
327
+ if (patch.displayName !== undefined) {
328
+ if (typeof patch.displayName !== 'string' || !patch.displayName.trim())
329
+ throw new IamError('invalid_input', 'displayName 无效');
330
+ row.display_name = patch.displayName.trim().slice(0, 128);
331
+ }
332
+ if (patch.disabled !== undefined) {
333
+ if (typeof patch.disabled !== 'boolean')
334
+ throw new IamError('invalid_input', 'disabled 必须是布尔值');
335
+ if (patch.disabled && !user.disabled)
336
+ await this.#assertOwnersRemain({ disable: id });
337
+ row.disabled = patch.disabled ? 1 : 0;
338
+ }
339
+ await this.#model('users').update(row).where({ id });
340
+ if (patch.disabled === true)
341
+ await this.#revokeCredentials(id, true, 'disabled');
342
+ await this.refreshOwnerState();
343
+ return (await this.getUser(id));
344
+ });
345
+ }
346
+ setDisabled(id, disabled) {
347
+ return this.updateUser(id, { disabled });
348
+ }
349
+ deleteUser(id) {
350
+ return this.#serialize(async () => {
351
+ await this.#requireUser(id);
352
+ await this.#assertOwnersRemain({ disable: id });
353
+ await this.#revokeCredentials(id, true, 'deleted');
354
+ await this.#model('bindings').delete().where({ user_id: id });
355
+ await this.#model('users').delete().where({ id });
356
+ await this.refreshOwnerState();
357
+ });
358
+ }
359
+ resetPassword(id, password) {
360
+ return this.#serialize(async () => {
361
+ await this.#requireUser(id);
362
+ await this.#setPassword(id, password);
363
+ });
364
+ }
365
+ async #setPassword(id, password) {
366
+ const problem = passwordProblem(password);
367
+ if (problem)
368
+ throw new IamError('invalid_input', problem);
369
+ const now = this.#now();
370
+ await this.#model('users').update({ password_hash: await hashPassword(password), password_changed_at: now, updated_at: now }).where({ id });
371
+ await this.#revokeCredentials(id, false, 'password');
372
+ }
373
+ /** Sessions always; API tokens too when the user is disabled or deleted (ADR 0049 §2). */
374
+ async #revokeCredentials(userId, tokens, reason) {
375
+ await this.#model('sessions').delete().where({ user_id: userId });
376
+ if (tokens)
377
+ await this.#model('tokens').delete().where({ user_id: userId });
378
+ this.#emitRevocation(userId, reason);
379
+ }
380
+ // ------------------------------------------------------------------ roles & bindings
381
+ async listRoles() {
382
+ const catalog = this.pluginPermissionCatalog();
383
+ return (await this.#rawRoles()).map(role => withPluginPermissions(role, catalog));
384
+ }
385
+ /** The loaded plugins' permission catalog (what the Console's role editor offers). */
386
+ pluginPermissionCatalog() {
387
+ try {
388
+ return this.#pluginPermissions();
389
+ }
390
+ catch (error) {
391
+ this.#onError(error);
392
+ return [];
393
+ }
394
+ }
395
+ /**
396
+ * Plugin permission ids in `permissions` that no loaded plugin uses (accepted, but they grant
397
+ * nothing until a plugin uses them); for the warnings of role and token writes.
398
+ */
399
+ unknownPluginPermissions(permissions) {
400
+ const known = new Set(this.pluginPermissionCatalog().map(entry => entry.id));
401
+ return Object.freeze(permissions.filter(permission => isConsolePluginPermission(permission) && !known.has(permission)));
402
+ }
403
+ async #rawRoles() {
404
+ this.#requireReady();
405
+ const rows = await this.#model('roles').select('id', 'name', 'description', 'builtin', 'permissions').orderBy('name', 'ASC');
406
+ return rows.map(roleView);
407
+ }
408
+ createRole(input) {
409
+ return this.#serialize(async () => {
410
+ this.#requireReady();
411
+ const name = typeof input.name === 'string' ? input.name.trim() : '';
412
+ if (!ROLE_NAME.test(name))
413
+ throw new IamError('invalid_input', '角色名需为 2-64 位小写字母、数字、_ 或 -');
414
+ const [existing] = await this.#model('roles').select('id').where({ name });
415
+ if (existing)
416
+ throw new IamError('conflict', '角色名已存在');
417
+ const id = newId();
418
+ await this.#model('roles').insert({ id, name, description: descriptionOf(input.description), builtin: 0, permissions: JSON.stringify(permissionList(input.permissions)) });
419
+ return withPluginPermissions((await this.#role(id)), this.pluginPermissionCatalog());
420
+ });
421
+ }
422
+ updateRole(id, patch) {
423
+ return this.#serialize(async () => {
424
+ const role = await this.#role(id);
425
+ if (!role)
426
+ throw new IamError('not_found', '角色不存在');
427
+ if (role.builtin)
428
+ throw new IamError('forbidden', '内置角色不可修改,请复制后再改');
429
+ const row = {};
430
+ if (patch.description !== undefined)
431
+ row.description = descriptionOf(patch.description);
432
+ if (patch.permissions !== undefined)
433
+ row.permissions = JSON.stringify(permissionList(patch.permissions));
434
+ if (Object.keys(row).length)
435
+ await this.#model('roles').update(row).where({ id });
436
+ return withPluginPermissions((await this.#role(id)), this.pluginPermissionCatalog());
437
+ });
438
+ }
439
+ deleteRole(id) {
440
+ return this.#serialize(async () => {
441
+ const role = await this.#role(id);
442
+ if (!role)
443
+ throw new IamError('not_found', '角色不存在');
444
+ if (role.builtin)
445
+ throw new IamError('forbidden', '内置角色不可删除');
446
+ await this.#model('bindings').delete().where({ role_id: id });
447
+ await this.#model('roles').delete().where({ id });
448
+ });
449
+ }
450
+ /** Replaces all bindings of a user. */
451
+ setBindings(userId, inputs) {
452
+ return this.#serialize(async () => {
453
+ await this.#requireUser(userId);
454
+ if (!Array.isArray(inputs) || inputs.length > 64)
455
+ throw new IamError('invalid_input', 'bindings 必须是数组');
456
+ const bindings = await this.#resolveBindings(inputs);
457
+ const keepsOwner = bindings.some(binding => binding.role_id === OWNER_ROLE_ID && binding.scope_kind === 'global');
458
+ if (!keepsOwner)
459
+ await this.#assertOwnersRemain({ disable: userId });
460
+ await this.#model('bindings').delete().where({ user_id: userId });
461
+ for (const binding of bindings)
462
+ await this.#model('bindings').insert({ id: newId(), user_id: userId, ...binding });
463
+ await this.refreshOwnerState();
464
+ // Grants may have shrunk: long-lived connections re-authorize from scratch.
465
+ this.#emitRevocation(userId, 'bindings');
466
+ return (await this.getUser(userId));
467
+ });
468
+ }
469
+ async #resolveBindings(inputs) {
470
+ const roles = await this.#rawRoles();
471
+ const result = [];
472
+ const seen = new Set();
473
+ for (const input of inputs) {
474
+ if (!input || typeof input !== 'object')
475
+ throw new IamError('invalid_input', 'binding 无效');
476
+ const role = roles.find(candidate => candidate.id === input.role || candidate.name === input.role);
477
+ if (!role)
478
+ throw new IamError('invalid_input', `角色不存在:${String(input.role)}`);
479
+ const scope = parseConsoleBindingScope(input.scopeKind ?? 'global', input.scopeKey ?? null);
480
+ if (!scope)
481
+ throw new IamError('invalid_input', '作用域无效(global / adapter:<别名> / endpoint:<别名>/<id>)');
482
+ const key = `${role.id}|${scope.kind}|${scope.kind === 'global' ? '' : scope.key}`;
483
+ if (seen.has(key))
484
+ continue;
485
+ seen.add(key);
486
+ result.push({ role_id: role.id, scope_kind: scope.kind, scope_key: scope.kind === 'global' ? null : scope.key });
487
+ }
488
+ return result;
489
+ }
490
+ /** After setup there must always be ≥1 enabled user with a global owner binding (ADR 0049 §5). */
491
+ async #assertOwnersRemain(change) {
492
+ const owners = await this.#activeOwnerIds();
493
+ if (owners.size === 0)
494
+ return;
495
+ owners.delete(change.disable);
496
+ if (owners.size === 0)
497
+ throw new IamError('last_owner', '不能移除最后一个所有者');
498
+ }
499
+ async #activeOwnerIds() {
500
+ if (!this.#ready)
501
+ return new Set();
502
+ const bindings = await this.#model('bindings').select('user_id').where({ role_id: OWNER_ROLE_ID, scope_kind: 'global' });
503
+ const ids = new Set();
504
+ for (const binding of bindings) {
505
+ const id = String(binding.user_id);
506
+ if (await this.#enabledUser(id))
507
+ ids.add(id);
508
+ }
509
+ return ids;
510
+ }
511
+ // ------------------------------------------------------------------ API tokens
512
+ async listTokens(userId) {
513
+ this.#requireReady();
514
+ const select = this.#model('tokens').select('id', 'user_id', 'name', 'permissions', 'expires_at', 'last_used_at', 'created_at');
515
+ const rows = await (userId ? select.where({ user_id: userId }) : select).orderBy('created_at', 'DESC');
516
+ return rows.map(tokenView);
517
+ }
518
+ /** Returns the plaintext once; only its SHA-256 is stored. */
519
+ async createToken(userId, input) {
520
+ await this.#requireUser(userId);
521
+ const name = typeof input.name === 'string' && input.name.trim() ? input.name.trim().slice(0, 128) : '';
522
+ if (!name)
523
+ throw new IamError('invalid_input', 'name 必填');
524
+ const permissions = input.permissions === undefined || input.permissions === null ? null : permissionList(input.permissions);
525
+ let expiresAt = null;
526
+ if (input.expiresAt !== undefined && input.expiresAt !== null) {
527
+ if (typeof input.expiresAt !== 'number' || !Number.isSafeInteger(input.expiresAt) || input.expiresAt <= this.#now()) {
528
+ throw new IamError('invalid_input', 'expiresAt 必须是将来的毫秒时间戳');
529
+ }
530
+ expiresAt = input.expiresAt;
531
+ }
532
+ const token = generateToken();
533
+ const id = newId();
534
+ await this.#model('tokens').insert({
535
+ id, user_id: userId, name, token_hash: sha256(token), permissions: permissions ? JSON.stringify(permissions) : null,
536
+ expires_at: expiresAt, last_used_at: null, created_at: this.#now(),
537
+ });
538
+ const [row] = await this.#model('tokens').select('id', 'user_id', 'name', 'permissions', 'expires_at', 'last_used_at', 'created_at').where({ id });
539
+ return { token, record: tokenView(row) };
540
+ }
541
+ /** `ownerUserId` restricts the revoke to that user's own tokens (self-service). */
542
+ async revokeToken(id, ownerUserId) {
543
+ const [row] = await this.#model('tokens').select('id', 'user_id').where({ id: String(id) });
544
+ if (!row || (ownerUserId !== undefined && String(row.user_id) !== ownerUserId))
545
+ throw new IamError('not_found', '令牌不存在');
546
+ await this.#model('tokens').delete().where({ id: String(id) });
547
+ }
548
+ // ------------------------------------------------------------------ audit
549
+ async listAudit(filter = {}) {
550
+ this.#requireReady();
551
+ const where = {};
552
+ if (typeof filter.actor === 'string' && filter.actor)
553
+ where.actor = filter.actor;
554
+ if (typeof filter.action === 'string' && filter.action)
555
+ where.action = filter.action;
556
+ const range = {};
557
+ if (typeof filter.since === 'number')
558
+ range.$gte = filter.since;
559
+ const upper = [filter.until, filter.before].filter((value) => typeof value === 'number');
560
+ if (upper.length)
561
+ range.$lt = Math.min(...upper);
562
+ if (Object.keys(range).length)
563
+ where.at = range;
564
+ const limit = typeof filter.limit === 'number' && filter.limit > 0 ? Math.min(Math.floor(filter.limit), 500) : 100;
565
+ const select = this.#model('audit').select('id', 'at', 'actor', 'action', 'target', 'outcome', 'detail', 'ip');
566
+ const rows = await (Object.keys(where).length ? select.where(where) : select).orderBy('at', 'DESC').limit(limit + 1);
567
+ const entries = rows.slice(0, limit).map(auditView);
568
+ return { entries, next: rows.length > limit ? entries[entries.length - 1].at : null };
569
+ }
570
+ /** Daily: audit older than the retention window and expired sessions. */
571
+ async cleanup() {
572
+ if (!this.#ready)
573
+ return;
574
+ const now = this.#now();
575
+ await this.#model('audit').delete().where({ at: { $lt: now - this.#retention } });
576
+ await this.#model('sessions').delete().where({ expires_at: { $lt: now } });
577
+ }
578
+ // ------------------------------------------------------------------ helpers
579
+ #model(key) {
580
+ const model = this.#db.models.get(IAM_TABLES[key]);
581
+ if (!model)
582
+ throw new IamError('unavailable', '访问控制数据表不可用');
583
+ return model;
584
+ }
585
+ #requireReady() {
586
+ if (!this.#ready)
587
+ throw new IamError('unavailable', '数据库尚未就绪');
588
+ }
589
+ async #requireUser(id) {
590
+ this.#requireReady();
591
+ const user = typeof id === 'string' && id ? await this.getUser(id) : null;
592
+ if (!user)
593
+ throw new IamError('not_found', '用户不存在');
594
+ return user;
595
+ }
596
+ async #enabledUser(id) {
597
+ const [row] = await this.#model('users').select('id', 'username', 'display_name', 'disabled').where({ id });
598
+ return row && Number(row.disabled) !== 1 ? row : undefined;
599
+ }
600
+ async #role(id) {
601
+ const [row] = await this.#model('roles').select('id', 'name', 'description', 'builtin', 'permissions').where({ id });
602
+ return row ? roleView(row) : null;
603
+ }
604
+ async #allBindings() {
605
+ const roles = new Map((await this.#rawRoles()).map(role => [role.id, role]));
606
+ const rows = await this.#model('bindings').select('id', 'user_id', 'role_id', 'scope_kind', 'scope_key');
607
+ return rows.flatMap(row => {
608
+ const scope = parseConsoleBindingScope(row.scope_kind, row.scope_key ?? null);
609
+ const role = roles.get(String(row.role_id));
610
+ return scope && role ? [{ id: String(row.id), userId: String(row.user_id), roleId: role.id, role: role.name, scope }] : [];
611
+ });
612
+ }
613
+ async #principal(user, kind, credentialId, narrow) {
614
+ const roles = new Map((await this.#rawRoles()).map(role => [role.id, role]));
615
+ const levelOf = narrow?.some(isConsolePluginPermission) ? pluginPermissionLevelResolver(this.pluginPermissionCatalog()) : undefined;
616
+ const rows = await this.#model('bindings').select('role_id', 'scope_kind', 'scope_key').where({ user_id: user.id });
617
+ const grants = [];
618
+ const names = new Set();
619
+ for (const row of rows) {
620
+ const role = roles.get(String(row.role_id));
621
+ const scope = parseConsoleBindingScope(row.scope_kind, row.scope_key ?? null);
622
+ if (!role || !scope)
623
+ continue;
624
+ names.add(role.name);
625
+ // API tokens never exceed the issuing user: intersect at every request.
626
+ const permissions = narrow ? narrowPermissions(role.permissions, narrow, levelOf) : role.permissions;
627
+ grants.push(Object.freeze({ role: role.name, scope, permissions: Object.freeze([...permissions]) }));
628
+ }
629
+ return Object.freeze({
630
+ kind,
631
+ userId: String(user.id),
632
+ username: String(user.username),
633
+ displayName: String(user.display_name),
634
+ credentialId,
635
+ grants: Object.freeze(grants),
636
+ roles: Object.freeze([...names].sort()),
637
+ });
638
+ }
639
+ /**
640
+ * Bookkeeping writes of authentication (sliding expiry, last use, expired-row cleanup): a
641
+ * database that stays locked past the retry budget must not turn a valid credential into a 401.
642
+ */
643
+ async #touch(write) {
644
+ try {
645
+ await write();
646
+ }
647
+ catch (error) {
648
+ if (!isDatabaseBusyError(error))
649
+ throw error;
650
+ this.#onError(error);
651
+ }
652
+ }
653
+ #serialize(operation) {
654
+ const run = this.#mutation.then(operation, operation);
655
+ this.#mutation = run.catch(() => undefined);
656
+ return run;
657
+ }
658
+ }
659
+ function actorOf(principal) {
660
+ return principal.kind === 'user' ? `user:${principal.userId}` : `api-token:${principal.credentialId}`;
661
+ }
662
+ function parseList(value) {
663
+ if (typeof value !== 'string' || !value)
664
+ return null;
665
+ try {
666
+ const parsed = JSON.parse(value);
667
+ return Array.isArray(parsed) ? parsed.filter((item) => typeof item === 'string') : null;
668
+ }
669
+ catch {
670
+ return null;
671
+ }
672
+ }
673
+ /**
674
+ * Role permissions ∩ the token's list. A plugin permission the token lists by id survives when
675
+ * the role holds it by id or through its level (`levelOf`: declared levels, else inferred).
676
+ */
677
+ function narrowPermissions(rolePermissions, narrow, levelOf) {
678
+ const out = new Set(rolePermissions.filter(permission => narrow.includes(permission)));
679
+ for (const permission of narrow) {
680
+ if (permissionListHoldsPluginPermission(rolePermissions, permission, levelOf))
681
+ out.add(permission);
682
+ }
683
+ return [...out];
684
+ }
685
+ /** Effective plugin permissions of a role over the loaded catalog (explicit ids first-class). */
686
+ function withPluginPermissions(role, catalog) {
687
+ const byId = new Map(catalog.map(entry => [entry.id, entry]));
688
+ const levelOf = pluginPermissionLevelResolver(catalog);
689
+ const out = new Map();
690
+ for (const permission of role.permissions) {
691
+ if (!isConsolePluginPermission(permission))
692
+ continue;
693
+ const entry = byId.get(permission);
694
+ out.set(permission, Object.freeze({
695
+ id: permission,
696
+ level: consolePluginPermissionLevel(permission, levelOf),
697
+ source: 'explicit',
698
+ known: entry !== undefined,
699
+ ...(entry?.description ? { description: entry.description } : {}),
700
+ }));
701
+ }
702
+ for (const entry of catalog) {
703
+ if (out.has(entry.id) || !role.permissions.includes(CONSOLE_PLUGIN_LEVEL_PERMISSIONS[entry.level]))
704
+ continue;
705
+ out.set(entry.id, Object.freeze({
706
+ id: entry.id, level: entry.level, source: 'level', known: true,
707
+ ...(entry.description ? { description: entry.description } : {}),
708
+ }));
709
+ }
710
+ const pluginPermissions = Object.freeze([...out.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)));
711
+ return Object.freeze({ ...role, pluginPermissions });
712
+ }
713
+ /**
714
+ * Catalog permissions plus plugin permissions (`<plugin>:<action>`). A plugin id need not be
715
+ * loaded right now (plugins are installed, disabled and hot-reloaded independently of roles);
716
+ * callers report unloaded ones as warnings. It grants nothing until a plugin uses it.
717
+ */
718
+ function permissionList(value) {
719
+ if (!Array.isArray(value))
720
+ throw new IamError('invalid_input', 'permissions 必须是数组');
721
+ if (value.length > 512)
722
+ throw new IamError('invalid_input', 'permissions 过多');
723
+ const unknown = value.filter(item => !isConsolePermission(item) && !isConsolePluginPermission(item));
724
+ if (unknown.length)
725
+ throw new IamError('invalid_input', `未知权限:${unknown.map(String).join(', ').slice(0, 200)}`);
726
+ return Object.freeze([...new Set(value)].sort());
727
+ }
728
+ function descriptionOf(value) {
729
+ return typeof value === 'string' ? value.trim().slice(0, 512) : '';
730
+ }
731
+ function userView(row, bindings) {
732
+ return Object.freeze({
733
+ id: String(row.id),
734
+ username: String(row.username),
735
+ displayName: String(row.display_name),
736
+ disabled: Number(row.disabled) === 1,
737
+ createdAt: Number(row.created_at),
738
+ updatedAt: Number(row.updated_at),
739
+ passwordChangedAt: Number(row.password_changed_at),
740
+ bindings: Object.freeze(bindings.map(({ id, roleId, role, scope }) => Object.freeze({ id, roleId, role, scope }))),
741
+ });
742
+ }
743
+ function roleView(row) {
744
+ return Object.freeze({
745
+ id: String(row.id),
746
+ name: String(row.name),
747
+ description: String(row.description ?? ''),
748
+ builtin: Number(row.builtin) === 1,
749
+ permissions: Object.freeze([...(parseList(row.permissions) ?? [])]),
750
+ });
751
+ }
752
+ function tokenView(row) {
753
+ const optional = (value) => (value === null || value === undefined ? null : Number(value));
754
+ return Object.freeze({
755
+ id: String(row.id),
756
+ userId: String(row.user_id),
757
+ name: String(row.name),
758
+ permissions: parseList(row.permissions),
759
+ expiresAt: optional(row.expires_at),
760
+ lastUsedAt: optional(row.last_used_at),
761
+ createdAt: Number(row.created_at),
762
+ });
763
+ }
764
+ function auditView(row) {
765
+ let detail = null;
766
+ if (typeof row.detail === 'string') {
767
+ try {
768
+ detail = JSON.parse(row.detail);
769
+ }
770
+ catch {
771
+ detail = null;
772
+ }
773
+ }
774
+ return Object.freeze({
775
+ id: String(row.id),
776
+ at: Number(row.at),
777
+ actor: String(row.actor),
778
+ action: String(row.action),
779
+ target: row.target === null || row.target === undefined ? null : String(row.target),
780
+ outcome: String(row.outcome),
781
+ detail,
782
+ ip: row.ip === null || row.ip === undefined ? null : String(row.ip),
783
+ });
784
+ }