@h-ai/iam 0.1.0-alpha8 → 1.0.0-beta.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.
package/README.en.md ADDED
@@ -0,0 +1,965 @@
1
+ # @h-ai/iam
2
+
3
+ [简体中文](./README.md) | [English](./README.en.md) | [日本語](./README.ja.md)
4
+
5
+ [Framework home](../../README.en.md) · Related modules: [@h-ai/audit](../audit/README.en.md) · [@h-ai/cache](../cache/README.en.md) · [@h-ai/core](../core/README.en.md) · [@h-ai/crypto](../crypto/README.en.md) · [@h-ai/reldb](../reldb/README.en.md) · [@h-ai/reach](../reach/README.en.md)
6
+
7
+ ## Introduction
8
+
9
+ Identity and access management through a unified `iam` object for authentication, sessions, authorization, and user management.
10
+
11
+ ## Core features
12
+
13
+ | Feature | Description |
14
+ | -------------------- | ---------------------------------------------------------------------------------------------------------------------- |
15
+ | **Authentication** | Password, OTP (email/SMS codes), LDAP, and API Key strategies |
16
+ | **Sessions** | Stateful sessions (random access tokens + cache, optional sliding renewal) |
17
+ | **Authorization** | RBAC roles and permissions (batched DB reads, wildcard matching) |
18
+ | **User management** | Registration, queries, updates, password reset, and admin password reset |
19
+ | **API Key** | Create, revoke, and verify API keys with scopes and expiration |
20
+ | **One-time tickets** | Short-lived capability tickets (issue / atomic consume) for channels such as WebSocket that cannot carry Bearer tokens |
21
+ | **Frontend client** | HTTP API contracts for independent frontends |
22
+
23
+ ## Getting started
24
+
25
+ ### Installation
26
+
27
+ ```bash
28
+ pnpm add @h-ai/iam
29
+ ```
30
+
31
+ ### Dependencies
32
+
33
+ - `@h-ai/reldb`: persistence for users/roles/permissions; **initialize before iam.init()**.
34
+ - `@h-ai/cache`: sessions/OTP/one-time tickets; **initialize before iam.init()**.
35
+ - `@h-ai/crypto`: password hashing, used internally and initialized automatically.
36
+ - `@h-ai/audit`: automatic IAM security events after host initialization. An unused collector is not called; enabled audit failures remain observable and caller-owned transactions must roll back.
37
+
38
+ - `@h-ai/reach`: **optional**, installed, initialized and injected by the application for standard notifications; custom callbacks do not require Reach.
39
+ - `ldapts`: **optional**, loaded only when LDAP is explicitly enabled without an injected `ldapClientFactory`.
40
+
41
+ ### Optional LDAP and notifications
42
+
43
+ Password authentication, users and RBAC work without LDAP or Reach. Both are optional peer dependencies: install only the integrations you use. The snippets below run inside application startup/authentication functions after reldb/cache initialization; `username/password` are validated input.
44
+
45
+ #### LDAP / Active Directory
46
+
47
+ For the default client run `pnpm add ldapts`, then explicitly set `login.ldap: true` and connection settings. Connection settings alone do not enable LDAP. Enabling the default client without the SDK returns an initialization configuration error.
48
+
49
+ ```yaml
50
+ # config/_iam.yml(启动前设置 HAI_IAM_LDAP_BINDPASSWORD)
51
+ login:
52
+ ldap: true
53
+ ldap:
54
+ url: ldaps://ldap.example.com:636
55
+ bindDn: cn=service,dc=example,dc=com
56
+ bindPassword: '${HAI_IAM_LDAP_BINDPASSWORD}'
57
+ searchBase: ou=users,dc=example,dc=com
58
+ searchFilter: '(uid={{username}})'
59
+ usernameAttribute: uid
60
+ emailAttribute: mail
61
+ displayNameAttribute: cn
62
+ connectTimeout: 5000
63
+ ```
64
+
65
+ `ldaps://` uses TLS; `ldap://` with `useTls: true` uses StartTLS. For Active Directory use `searchFilter: "(sAMAccountName={{username}})"` and `usernameAttribute: sAMAccountName`; the username placeholder is escaped before searching. First login synchronizes a local user and assigns the default role; disabled accounts and lockouts still apply. `ldapSyncUser: false` requires an existing active, unexpired local user with a database credential generation; no virtual DN user is created. You may inject a `ldapClientFactory` returning `bind/search/unbind` methods with HaiResult results. An injected factory does not require ldapts.
66
+
67
+ ```ts
68
+ import type { IamConfigSettingsInput } from '@h-ai/iam'
69
+ import { core } from '@h-ai/core'
70
+ import { iam } from '@h-ai/iam'
71
+
72
+ // core 已加载 config/,reldb 与 cache 已成功初始化。
73
+ const initialized = await iam.init(core.config.getOrThrow<IamConfigSettingsInput>('iam'))
74
+ if (!initialized.success)
75
+ return initialized
76
+ const result = await iam.auth.loginWithLdap({ username, password })
77
+ if (!result.success)
78
+ return result
79
+ return result
80
+ ```
81
+
82
+ #### Automatic delivery with optional Reach
83
+
84
+ Run `pnpm add @h-ai/reach`. The application configures, initializes and closes Reach; IAM uses only the injected instance. These SMTP/HTTP providers are examples; the SMS gateway accepts the [Reach notification interface](../reach/README.en.md). Console logs metadata and does not deliver messages.
85
+
86
+ ```yaml
87
+ # config/_reach.yml;环境变量仅在服务端设置。
88
+ providers:
89
+ - name: email
90
+ type: smtp
91
+ host: '${SMTP_HOST}'
92
+ port: 465
93
+ secure: true
94
+ user: '${SMTP_USER}'
95
+ pass: '${SMTP_PASSWORD}'
96
+ from: '${MAIL_FROM}'
97
+ - name: sms
98
+ type: api
99
+ url: '${SMS_GATEWAY_URL}'
100
+ templates:
101
+ - name: otp_email
102
+ provider: email
103
+ subject: Verification code
104
+ body: 'Your code is {code}.'
105
+ - name: otp_sms
106
+ provider: sms
107
+ body: 'Your code is {code}.'
108
+ - name: password_reset
109
+ provider: email
110
+ subject: Password reset
111
+ body: 'Reset: {resetUrl}. Expires: {expiresAt}.'
112
+ - name: password_reset_sms
113
+ provider: sms
114
+ body: 'Reset: {resetUrl}. Expires: {expiresAt}.'
115
+ ```
116
+
117
+ Standard templates: `otp_email / otp_sms` use `{code}`; `password_reset / password_reset_sms` use `{token}`, `{expiresAt}`, `{resetUrl}`. `passwordReset.mode: phone` selects SMS reset. Configure templates before `iam.init()`; unavailable channels are hidden by the public configuration.
118
+
119
+ ```ts
120
+ import type { ReachConfigInput } from '@h-ai/reach'
121
+ import { core } from '@h-ai/core'
122
+ import { iam } from '@h-ai/iam'
123
+ import { reach } from '@h-ai/reach'
124
+
125
+ // core、reldb、cache 已初始化;按应用生命周期反序关闭 IAM 与 Reach。
126
+ const notification = await reach.init(core.config.getOrThrow<ReachConfigInput>('reach'))
127
+ if (!notification.success)
128
+ return notification
129
+ const initialized = await iam.init({
130
+ reach,
131
+ login: { otp: true },
132
+ otp: { email: true, sms: true },
133
+ passwordReset: { mode: 'email', resetUrl: 'https://app.example.com/auth/reset-password' },
134
+ })
135
+ if (!initialized.success)
136
+ return initialized
137
+ return iam.auth.sendOtp('user@example.com')
138
+ ```
139
+
140
+ #### Custom email and SMS delivery
141
+
142
+ Inject `onOtpSendEmail`, `onOtpSendSms` and `onPasswordResetRequest` to use existing application services. Implement only needed callbacks or mix them with Reach; explicit callbacks take priority. These services are application implementations whose promises resolve only after successful delivery. If a service returns HaiResult, check `success` and throw inside the callback on failure. IAM catches callback exceptions and returns a business error without exposing codes, tokens or internal delivery details.
143
+
144
+ ```ts
145
+ import { iam } from '@h-ai/iam'
146
+ import { mailService, smsService } from './notifications.js'
147
+
148
+ // mailService/smsService 为应用已有的发送服务;不需要安装或初始化 Reach。
149
+ // 调用方须检查服务投递结果,失败时拒绝 Promise,不能吞掉错误。
150
+ const initialized = await iam.init({
151
+ otp: { email: true, sms: true },
152
+ onOtpSendEmail: async (email, code, context) => {
153
+ await mailService.send({ to: email, subject: 'Verification code', text: code, idempotencyKey: context.idempotencyKey })
154
+ },
155
+ onOtpSendSms: async (phone, code, context) => {
156
+ await smsService.send({ to: phone, code, idempotencyKey: context.idempotencyKey })
157
+ },
158
+ passwordReset: { mode: 'email' },
159
+ onPasswordResetRequest: async (user, token, context) => {
160
+ if (!user.email)
161
+ throw new Error('Reset recipient unavailable')
162
+ const url = new URL('https://app.example.com/auth/reset-password')
163
+ url.searchParams.set('token', token)
164
+ await mailService.send({ to: user.email, subject: 'Password reset', text: url.toString(), expiresAt: context.expiresAt, idempotencyKey: context.idempotencyKey })
165
+ },
166
+ })
167
+ if (!initialized.success)
168
+ return initialized
169
+ ```
170
+
171
+ ### Quick start
172
+
173
+ ```ts
174
+ import { cache } from '@h-ai/cache'
175
+ import { core } from '@h-ai/core'
176
+ import { iam } from '@h-ai/iam'
177
+ import { reldb } from '@h-ai/reldb'
178
+
179
+ // 基础密码认证不需要安装 Reach 或 ldapts。
180
+ async function example() {
181
+ try {
182
+ const db = await reldb.init({ type: 'sqlite', database: './data.db' })
183
+ if (!db.success)
184
+ return db
185
+ const cached = await cache.init({ type: 'memory' })
186
+ if (!cached.success)
187
+ return cached
188
+ const initialized = await iam.init({})
189
+ if (!initialized.success)
190
+ return initialized
191
+ const registered = await iam.user.register({ username: 'demo', password: 'Password123' })
192
+ if (!registered.success)
193
+ return registered
194
+ const login = await iam.auth.login({ identifier: 'demo', password: 'Password123' })
195
+ if (!login.success)
196
+ return login
197
+ return await iam.auth.verifyToken(login.data.tokens.accessToken)
198
+ }
199
+ finally {
200
+ // 应用关闭时按依赖的反向顺序释放。
201
+ await iam.close()
202
+ await cache.close()
203
+ await reldb.close()
204
+ }
205
+ }
206
+
207
+ const result = await example()
208
+ if (!result.success)
209
+ core.logger.error('Authentication example failed', { code: result.error.code })
210
+ ```
211
+
212
+ LDAP is disabled by default. Public OTP methods require `otp` settings and send callbacks; password reset requires a notification callback.
213
+
214
+ `iam.config` returns a **redacted configuration snapshot**. Sensitive LDAP `url` / `bindPassword` and similar fields are hidden automatically. Use it to inspect state, not as the original configuration source.
215
+
216
+ ### Design
217
+
218
+ #### Architecture
219
+
220
+ IAM is a **lifecycle singleton** managed through `iam.init()` / `iam.close()`. Five internal subsystems initialize in dependency order:
221
+
222
+ ```
223
+ iam.init()
224
+ ├─ session (cached sessions; current credential state uses shared cache/DB fallback)
225
+ ├─ authz (RBAC roles/permissions; batched DB reads)
226
+ ├─ authn (authentication; depends on session + authz)
227
+ ├─ user (users; depends on session + authz + passwordStrategy)
228
+ └─ seed (seed data; depends on authz)
229
+ ```
230
+
231
+ All subsystems use **factories + closures** (for example `createSessionOperations(deps)`), not classes. Getters expose them as `iam.auth` / `iam.user` / `iam.authz` / `iam.session` / `iam.apiKey`.
232
+
233
+ Before initialization, every subsystem method returns `NOT_INITIALIZED` through a `NotInitializedKit` proxy.
234
+
235
+ #### Data storage
236
+
237
+ | Data | Storage | Description |
238
+ | ------------------------------------------------- | -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
239
+ | Users, roles, permissions, associations, API keys | DB (5 base tables, 1 optional API Key table) | `hai_iam_users`、`hai_iam_roles`、`hai_iam_permissions`、`hai_iam_role_permissions`、`hai_iam_user_roles`、`hai_iam_api_keys` |
240
+ | Sessions (accessToken → Session) | Cache | TTL, optional sliding renewal |
241
+ | Refresh state | Redis user partition | Atomic with session/index |
242
+ | OTP codes | Cache | TTL + attempt limit |
243
+ | Password reset tokens | User database row | SM3 hash, DB expiry, attempts and conditional password commit |
244
+ | API Key | DB | `hai_iam_api_keys`; store hashes only, never plaintext; prefix narrows candidates |
245
+
246
+ ---
247
+
248
+ #### Authentication (authn) — `iam.auth`
249
+
250
+ ##### Key types
251
+
252
+ - **`AuthnOperations`**: `login` / `loginWithOtp` / `loginWithLdap` / `loginWithApiKey` / `logout` / `verifyToken` / `sendOtp` / `registerAndLogin`.
253
+ - **`AuthStrategy`**: strategy interface (`type` + `authenticate` + `challenge?`), implemented for password, OTP, LDAP, and API Key.
254
+ - **`AuthResult`**: login result with user details, `TokenPair` (accessToken + refreshToken), and roles/permissions.
255
+ - **`Credentials`**: a credential union combining `{ type: 'password' | 'otp' | 'ldap' | 'apikey' }` with strategy-specific credentials.
256
+
257
+ ##### Key workflows
258
+
259
+ **Password login:**
260
+
261
+ ```
262
+ login(credentials)
263
+ → Check password login is enabled
264
+ → Find user by identifier (username / email / phone)
265
+ → Dummy hash for missing users (fixed timing to mitigate timing attacks)
266
+ → Check account lock (loginFailedCount >= maxLoginAttempts within lockoutDuration)
267
+ → Verify password hash (@h-ai/crypto)
268
+ → Failure: record attempt; lock at the limit
269
+ → Success: reset failure count
270
+ → Check password expiration (passwordUpdatedAt when expirationDays > 0)
271
+ → Query user roles + permissions
272
+ → Create session (session.create → TokenPair)
273
+ → Return AuthResult { user, tokens, roles, permissions, agreements? }
274
+ ```
275
+
276
+ **Token verification:**
277
+
278
+ ```
279
+ verifyToken(accessToken)
280
+ → session.get(accessToken) → cache lookup
281
+ → Read current user/credential state from the shared epoch cache; check status, expiry and session-bound credentialVersion, falling back to DB when the cache is not trustworthy
282
+ → Extend TTL automatically when sliding renewal is enabled
283
+ → Reuse that user profile and read current roles/permissions from the shared epoch cache; cold authorization reads use one JOIN
284
+ → Reject required state read failures, missing/disabled/expired users or credential-version mismatch; intersect API Key scopes again
285
+ → Return current Session (userId, roles, permissions)
286
+ ```
287
+
288
+ ##### Usage
289
+
290
+ These examples run on the server after successful reldb/cache/IAM initialization, with validated request input. Password login is available by default. OTP requires `login.otp: true`, an `otp` channel and delivery callbacks/Reach templates; LDAP requires `login.ldap: true` (see configuration above); API Key requires `login.apikey: true` (see the complete flow below). Login returns `AuthResult`; registration with login returns the same authentication structure. Registration and password login have separate switches.
291
+
292
+ OTP has two interactions: send a code, then log in with the code the user received. Both operations return HaiResult. LDAP uses `iam.auth.loginWithLdap({ username, password })`, and API Key uses `loginWithApiKey({ key })`. Check `success` before reading `data`.
293
+
294
+ ```ts
295
+ import { iam } from '@h-ai/iam'
296
+
297
+ async function passwordLogin(identifier: string, password: string) {
298
+ const result = await iam.auth.login({ identifier, password })
299
+ if (!result.success)
300
+ return result
301
+ // 成功后返回 user、tokens、roles、permissions;令牌只交给安全存储。
302
+ return result
303
+ }
304
+
305
+ async function requestLoginCode(identifier: string) {
306
+ // identifier 为启用通道对应的邮箱/手机号,返回 expiresAt。
307
+ return iam.auth.sendOtp(identifier)
308
+ }
309
+
310
+ async function otpLogin(identifier: string, code: string) {
311
+ // 用户收到验证码并输入后调用;发送失败时不能伪报成功。
312
+ return iam.auth.loginWithOtp({ identifier, code })
313
+ }
314
+
315
+ async function registerWithSession(username: string, password: string) {
316
+ return iam.auth.registerAndLogin({ username, password })
317
+ }
318
+ ```
319
+
320
+ ##### Considerations
321
+
322
+ - **Timing attack mitigation**: perform a dummy password hash for nonexistent users to keep response timing consistent.
323
+ - **Account lockout**: lock for `lockoutDuration` seconds after `maxLoginAttempts` consecutive failures.
324
+ - **Independent strategy toggles**: `login.password` / `login.otp` / `login.ldap` / `login.apikey`.
325
+ - **OTP security**: rejection sampling avoids modulo bias, constant-time comparison mitigates timing attacks, and `resendInterval` limits frequency.
326
+
327
+ `iam.auth.logout(accessToken)` uses the complete opaque token to revoke its access and refresh credentials in one user-partition CAS, including when access has expired. Invalid or absent tokens are idempotent successes. Cache read/commit failures return failure and can be retried with the same token after recovery. The HTTP endpoint revokes only a possessed token, accepts no userId target and does not require a still-authenticated access token. Callers must inspect `success`; clearing local cookies or secure storage is not confirmation of server revocation and failures must be shown.
328
+
329
+ ---
330
+
331
+ #### Sessions (session) — `iam.session`
332
+
333
+ ##### Key types
334
+
335
+ - **`SessionOperations`**: `create` / `get` / `verifyToken` / `update` / `delete` / `deleteByUserId` / `refresh` / `revokeRefresh` / `patchUserSessions`.
336
+ - **`Session`**: userId, roles, permissions, accessToken, expiration, and other session data.
337
+ - **`TokenPair`**: `{ accessToken, refreshToken, expiresIn, tokenType: 'Bearer' }`.
338
+
339
+ ##### Key workflows
340
+
341
+ **Token refresh (rotation):**
342
+
343
+ ```
344
+ refresh(refreshToken)
345
+ → Read the user partition and session version; validate authorization
346
+ → Build candidate tokens without publishing them
347
+ → Single-key CAS rechecks version/refresh and atomically replaces the old session
348
+ → Return the unique successor only after commit
349
+ ```
350
+
351
+ **Single-device login:**
352
+
353
+ ```
354
+ create(options) [singleDevice=true]
355
+ → Build candidate accessToken + refreshToken
356
+ → Single-key CAS replaces all old sessions and their index together
357
+ → Failed commits preserve the previous session
358
+ ```
359
+
360
+ ##### Usage
361
+
362
+ Use `tokens.accessToken` to authenticate requests and `tokens.refreshToken` to obtain a new pair; `expiresIn` is measured in seconds. Independent APIs use `Authorization: Bearer <accessToken>`. For SvelteKit Cookie endpoints, use kit's standard authentication integration described below.
363
+
364
+ Protected requests use `iam.auth.verifyToken` to read current user status, roles and permissions. `iam.session.get/verifyToken` exposes a lower-level session snapshot and does not replace current authorization. Only trusted server integrations should use `session.create`; never accept client-provided roles/permissions or API Key bindings.
365
+
366
+ ```ts
367
+ import { iam } from '@h-ai/iam'
368
+
369
+ async function authenticateRequest(accessToken: string) {
370
+ // 受保护请求读取当前用户状态及角色/权限,失败时停止业务操作。
371
+ const verified = await iam.auth.verifyToken(accessToken)
372
+ if (!verified.success)
373
+ return verified
374
+ return verified
375
+ }
376
+
377
+ async function rotateTokens(refreshToken: string) {
378
+ const refreshed = await iam.session.refresh(refreshToken)
379
+ if (!refreshed.success)
380
+ return refreshed
381
+ // 安全存储替换两个令牌;下一次请求仍走 auth.verifyToken。
382
+ return refreshed
383
+ }
384
+
385
+ async function signOut(accessToken: string) {
386
+ return iam.auth.logout(accessToken)
387
+ }
388
+
389
+ async function forceSignOut(userId: string) {
390
+ // 调用前已验证管理员权限,返回撤销的会话数。
391
+ return iam.session.deleteByUserId(userId)
392
+ }
393
+ ```
394
+
395
+ ##### Considerations
396
+
397
+ - **Cache-only storage**: Memory sessions are lost on restart; shared Redis sessions survive IAM service restarts until TTL or revocation.
398
+ - **Sliding renewal**: with `sliding: true`, each `get()` extends TTL automatically.
399
+ - **Refresh rotation**: old refreshToken expires after one use; replay detection indicates token leakage.
400
+ - **Token strength**: 256-bit cryptographic randomness (`crypto.getRandomValues(new Uint8Array(32))`), base64url encoded.
401
+
402
+ `create({ maxAge })` overrides the configured default with a positive safe integer in seconds, persisted as `Session.maxAge`. Issuance and refresh return that duration in `expiresIn`. Sliding renewal uses this duration and returns committed `lastActiveAt/expiresAt`; delayed requests cannot shorten a newer renewal. Explicit `update` applies valid date patches and creates a new content version. Clients schedule refresh from the returned `expiresIn` rather than a hardcoded default.
403
+
404
+ Access and refresh expire independently. The user partition remains until the latest live access/refresh deadline, while access authentication checks only its own `expiresAt`. An expired access token can still be rotated with a live refresh token. Each rotation starts a new `refreshTokenMaxAge` period and invalidates both old credentials. Access deletion, logoutAll and single-device replacement also revoke refresh-only records; revokeRefresh preserves a still-live access token. Refresh routes validate refresh credentials without requiring a live access token first.
405
+
406
+ Session cache read/commit failures return failed HaiResult values, never absence/expiry or cleanup compensation. Sessions and their index share one document, so batch updates no longer use per-token get/ttl calls or a separate index; cleanup uses confirmed absolute deadlines only. After recovery, re-read and retry updates/logout; logoutAll still covers every original record. Malformed state fails closed and is preserved for operator repair instead of being overwritten with an empty document.
407
+
408
+ Session.credentialVersion binds the database credential generation at creation. Only trusted authenticated strategies supply CreateSessionOptions.expectedCredentialVersion, and stale generations fail. Low-level create without that option still reads a real active, unexpired local user and binds its current generation; it cannot create an authenticated identity without a database record.
409
+
410
+ ---
411
+
412
+ #### Authorization (authz) — `iam.authz`
413
+
414
+ Role lists use `iam.authz.getRoleUserCounts(roleIds)` to aggregate actual membership for the current page in one query, returning `HaiResult<Map<string, number>>`. Missing relations mean zero; query failures display an error instead of invented zero counts.
415
+
416
+ ##### Key types
417
+
418
+ - **`AuthzOperations`**: role/permission CRUD, role-permission/user-role assignments, and permission checks.
419
+ - **`Role`**: `{ id, code, name, description, isSystem }`.
420
+ - **`Permission`**: `{ id, code, name, type, resource, action }`; type is `menu` / `api` / `button`.
421
+ - **`PermissionType`**: permission type enum.
422
+
423
+ ##### Key workflows
424
+
425
+ **Permission checks (`checkPermission`):**
426
+
427
+ ```
428
+ checkPermission(userId, permission)
429
+ → Return true if RBAC is disabled
430
+ → Read current roles/permissions from the shared epoch cache; cold reads use one JOIN
431
+ → Return true for the super-admin role; return errors on required state read failure
432
+ → Match each permission exactly or by wildcard (`admin:*` matches `admin:read`)
433
+ → Return true / false
434
+ ```
435
+
436
+ **Session synchronization after role/permission changes:**
437
+
438
+ ```
439
+ assignPermissionToRole(roleId, permId)
440
+ → Write association to DB
441
+ → Find role users → resolve permissions per user → update active sessions
442
+ (best-effort: failures only log.error; permission assignment still succeeds)
443
+ ```
444
+
445
+ ##### Usage
446
+
447
+ Create permissions, save permission **IDs** through the role's `permissionIds`, and assign a role by its **ID**. Session roles/permissions and guards use stable **codes**; display names can change independently. These examples assume RBAC is enabled. Applications must authorize administrative operations and check resource access before calling them.
448
+
449
+ `createRole/updateRole` saves role metadata and the complete permission set in one transaction: omission preserves it, an empty array clears it, duplicates are removed, and unknown permission IDs fail. Concurrent updates use the last committed complete set. Owned transactions synchronize sessions after commit; callers using an external transaction must synchronize after their commit.
450
+
451
+ `checkPermission(userId, code)` and `getUserAuthorization` read the subject's RBAC authorization without the requesting API Key's scopes. API Key requests must use scoped permissions returned by `iam.auth.verifyToken`, as shown below. Re-querying by userId alone can bypass Key restrictions. serv's `.permission('record:read')` declares an exact permission guard. Hiding a menu/button does not authorize the business API.
452
+
453
+ ```ts
454
+ import { iam } from '@h-ai/iam'
455
+
456
+ async function provisionReader(userId: string) {
457
+ // 示例初始化权限/角色;生产种子应先按 code 查询,保证幂等。
458
+ const permission = await iam.authz.createPermission({
459
+ code: 'record:read',
460
+ name: '读取记录',
461
+ type: 'api',
462
+ resource: 'record',
463
+ action: 'read',
464
+ })
465
+ if (!permission.success)
466
+ return permission
467
+ const role = await iam.authz.createRole({
468
+ code: 'record_reader',
469
+ name: '记录读取者',
470
+ permissionIds: [permission.data.id],
471
+ })
472
+ if (!role.success)
473
+ return role
474
+ return iam.authz.assignRole(userId, role.data.id)
475
+ }
476
+
477
+ async function replaceRolePermissions(roleId: string, permissionIds: string[]) {
478
+ // 完整替换:[] 清空;省略 permissionIds 则保持已有权限。
479
+ return iam.authz.updateRole(roleId, { permissionIds })
480
+ }
481
+
482
+ async function replaceUserRoles(userId: string, roleIds: string[]) {
483
+ // 完整替换用户的角色集合;[] 移除全部角色。
484
+ return iam.authz.syncRoles(userId, roleIds)
485
+ }
486
+
487
+ async function currentAuthorization(userId: string) {
488
+ // 返回 roles/permissions 的 code 数组,不接受 ID 或显示名称代替 code。
489
+ return iam.authz.getUserAuthorization(userId)
490
+ }
491
+ ```
492
+
493
+ ##### Considerations
494
+
495
+ - **Wildcard matching**: `admin:*` matches `admin:read`, `admin:write:detail`, etc.; one `*` matches everything after the colon.
496
+ - **Super-admin role**: `rbac.superAdminRole` (default `super_admin`) automatically grants all permissions.
497
+ - **Transactions**: `deleteRole` / `deletePermission` clear associations before deleting entities in a DB transaction.
498
+ - **Session synchronization**: cache updates are best-effort. Protected requests use `iam.auth.verifyToken` to check current user/credential state and authorization through the shared epoch cache, with DB fallback when needed. Required state read failure rejects authentication. `iam.session.get/verifyToken` reuses the same current user/credential read and checks status, expiry and `credentialVersion`, but its role/permission fields remain a session snapshot and do not replace current authorization. `iam.authz.getUserAuthorization(userId)` uses a single JOIN cold read and shared cache.
499
+ - **Seed data**: `seedDefaultData: true` idempotently creates admin/user/guest roles and basic permissions.
500
+
501
+ ---
502
+
503
+ #### Users (user) — `iam.user`
504
+
505
+ ##### Key types
506
+
507
+ - **`UserOperations`**: registration, queries, updates, deletion, password changes/resets, and password strength validation.
508
+ - **`User`** (public fields): `{ id, username, email, phone, displayName, avatarUrl, accountType?, accountStatus, accountExpiresAt?, roles? }`.
509
+ - **`StoredUser`**: internal stored user, including sensitive `passwordHash`, `loginFailedCount`, `lockedUntil`, etc.
510
+ - **`RegisterOptions`** / **`RegisterResult`**: registration input/output.
511
+ - **`UpdateCurrentUserInput`**: fields the current user may change (`username` / `email` / `displayName` / `avatarUrl` / `phone` / `metadata`).
512
+
513
+ ##### Key workflows
514
+
515
+ **User registration:**
516
+
517
+ ```
518
+ register(options)
519
+ → Check registration is enabled
520
+ → Validate password strength (Zod schema + custom rules)
521
+ → Hash password (@h-ai/crypto)
522
+ → DB transaction: create user + query result
523
+ → Assign default role (rbac.defaultRole; failure removes the new account and returns failure)
524
+ → Return RegisterResult { user, agreements? }
525
+ ```
526
+
527
+ **Password reset:**
528
+
529
+ ```
530
+ requestPasswordReset(email)
531
+ → Find user by email (missing users return ok silently to prevent enumeration)
532
+ → Generate a random 256-bit token
533
+ → Store the token digest, expiry, attempt count and resetVersion in the user database row
534
+ → Call onPasswordResetRequest (application sends email/SMS)
535
+
536
+ confirmPasswordReset(token, newPassword)
537
+ → Read the database digest and verify expiration, attempts and resetVersion
538
+ → Validate password strength → hash → update DB
539
+ → Clear all active user sessions to force login
540
+ → Consume the token, replace the password and advance credential generation in one conditional SQL update
541
+ ```
542
+
543
+ ##### Usage
544
+
545
+ `register` returns a user without a session; use `auth.registerAndLogin` when both are needed. `createUser` is an administrative operation independent of self-registration settings. Use `getCurrentUser/updateCurrentUser` for profile fields: username/email/displayName/avatarUrl/phone/metadata. Security fields and roles require authorized administrative operations.
546
+
547
+ Lists return `PaginatedResult<User>` and filter `roleCode` before pagination; request `include: ['roles']` only when needed. Bulk reads keep deduplicated input order and omit missing IDs, so associate results by id rather than array position. Returned users are public DTOs, but applications still control data scope and field disclosure.
548
+
549
+ `getUser(id)` returns `null` on a successful missing lookup; `getCurrentUser(token)` fails for an invalid session or missing user. Database failures are not empty results. `validatePassword` is synchronous and returns HaiResult. Deletion, disabling and password changes/resets revoke affected sessions.
550
+
551
+ ```ts
552
+ import { iam } from '@h-ai/iam'
553
+
554
+ async function registerUser(username: string, email: string, password: string) {
555
+ // 返回 RegisterResult,不自动建立会话。
556
+ return iam.user.register({ username, email, password })
557
+ }
558
+
559
+ async function createManagedUser(username: string, password: string) {
560
+ // 先验证调用者管理权限;不受 register.enabled 开关限制。
561
+ return iam.user.createUser({ username, password })
562
+ }
563
+
564
+ async function updateMyProfile(accessToken: string, displayName: string) {
565
+ return iam.user.updateCurrentUser(accessToken, { displayName })
566
+ }
567
+
568
+ async function changeMyPassword(accessToken: string, oldPassword: string, newPassword: string) {
569
+ // 成功后撤销该用户全部会话,客户端回到登录入口。
570
+ return iam.user.changeCurrentUserPassword(accessToken, oldPassword, newPassword)
571
+ }
572
+
573
+ async function listReaders(page: number) {
574
+ const result = await iam.user.listUsers({
575
+ page,
576
+ pageSize: 20,
577
+ roleCode: 'record_reader',
578
+ search: 'alice',
579
+ accountStatus: 'active',
580
+ include: ['roles'],
581
+ sortBy: 'createdAt',
582
+ sortDirection: 'desc',
583
+ })
584
+ if (!result.success)
585
+ return result
586
+ // 直接使用 items/total/page/pageSize,不对当前页再次过滤。
587
+ return result
588
+ }
589
+
590
+ async function loadUsers(userIds: readonly string[]) {
591
+ // 最多 1000 个输入 ID;去重保留输入顺序,缺失 ID 省略。
592
+ return iam.user.getUsers(userIds, { include: ['roles'] })
593
+ }
594
+
595
+ async function makeUserDormant(userId: string) {
596
+ // 调用前已验证管理权限;成功后自动清除该用户会话。
597
+ return iam.user.updateUser(userId, { accountStatus: 'dormant' })
598
+ }
599
+ ```
600
+
601
+ ##### Considerations
602
+
603
+ - **`toUser()` redaction**: public users omit sensitive fields such as `passwordHash` and `loginFailedCount`.
604
+ - **Session invalidation**: `deleteUser` / `updateUser({ accountStatus: 'dormant' })` / `changePassword` / `confirmPasswordReset` clear all active sessions for affected users.
605
+ - **Enumeration protection**: `requestPasswordReset` returns `ok(undefined)` for nonexistent emails instead of an error.
606
+ - **Synchronous validatePassword**: the only synchronous method in `UserOperations`; all others are asynchronous.
607
+ - **Fuzzy search**: `listUsers` searches username, email, phone, and display name with escaped LIKE wildcards.
608
+ - **Server sorting**: `listUsers` allows only `username`, `email`, `displayName`, `accountStatus`, and `createdAt`, mapped through an allowlist; defaults to newest creation first.
609
+
610
+ ---
611
+
612
+ #### API keys — `iam.apiKey`
613
+
614
+ `scopes` intersect the subject's permissions and support `*` and namespace `:*`. The default empty array grants identity only, without operation permissions. Restricted keys have empty session `roles` and use permission guards; explicit `*` never exceeds subject authorization. API Key login binds the key ID: get/verifyToken/refresh recheck its persistent state and reject revoked, disabled, expired keys or required state read failures. Standalone verifyApiKey returns verified key metadata and still requires subject/operation authorization.
615
+
616
+ ##### Key types
617
+
618
+ - **`ApiKeyOperations`**: `createApiKey` / `listApiKeys` / `getApiKey` / `revokeApiKey` / `verifyApiKey`.
619
+ - **`ApiKey`** (public fields): `{ id, userId, name, keyPrefix, enabled, expiresAt, scopes }`.
620
+ - **`CreateApiKeyResult`**: `{ apiKey, rawKey }`; the plaintext key is returned only once.
621
+
622
+ ##### Key workflows
623
+
624
+ **API key verification:**
625
+
626
+ ```
627
+ verifyApiKey(rawKey)
628
+ → Read the shared epoch cache by SHA-256(rawKey) fingerprint; check expiry on hits
629
+ → On a cold read, extract prefix from rawKey (prefix + first 8 characters)
630
+ → Query candidate keys by prefix from DB
631
+ → Verify each hash synchronously
632
+ → If valid, check expiration/disabled state
633
+ → Update lastUsedAt asynchronously without blocking return
634
+ → Return ApiKey
635
+ ```
636
+
637
+ ##### Usage
638
+
639
+ Enable API Key during initialization. A Key is a restricted user credential: **effective permissions = current subject permissions ∩ scopes**. Scopes use permission codes, not role/display names or resource IDs. They neither create permissions nor replace tenant/record ownership checks.
640
+
641
+ ```yaml
642
+ # config/_iam.yml
643
+ login:
644
+ apikey: true
645
+ apikey:
646
+ maxKeysPerUser: 10
647
+ defaultExpirationDays: 30
648
+ ```
649
+
650
+ For a subject holding `record:read`, `record:write` and `user:read`, the results are recalculated whenever subject authorization changes:
651
+
652
+ | scopes | Effective permissions | roles |
653
+ | ------------------------------ | --------------------------------------- | ---------------------- |
654
+ | Omitted or `[]` | Identity only, no operation permissions | `[]` |
655
+ | `['record:read']` | `record:read` | `[]` |
656
+ | `['record:*']` | `record:read`, `record:write` | `[]` |
657
+ | `['record:read', 'user:read']` | Both specified permissions | `[]` |
658
+ | `['missing:read']` | None, because the subject lacks it | `[]` |
659
+ | `['*']` | All current subject permissions | Subject roles retained |
660
+
661
+ `record:*` matches every level after the colon; arbitrary patterns such as `record:r*` are unsupported. If the subject only holds `record:read`, `['record:*']` still grants only read. Intersections with subject wildcards keep the narrower range. Restricted Keys have no role authorization; use permission guards. Choose explicit `*` only when broad access is intended.
662
+
663
+ The server examples cover creation → Key login → Bearer verification and operation authorization → listing → revocation. Administrative endpoints must verify ownership or administrator rights; IAM does not know the caller's business identity.
664
+
665
+ ```ts
666
+ import { err, HaiCommonError, ok } from '@h-ai/core'
667
+ import { iam } from '@h-ai/iam'
668
+
669
+ async function createReadKey(userId: string) {
670
+ // 调用前校验管理权限或本人归属;scopes 使用权限 code。
671
+ const created = await iam.apiKey.createApiKey(userId, {
672
+ name: 'Read client',
673
+ expirationDays: 30,
674
+ scopes: ['record:read'],
675
+ })
676
+ if (!created.success)
677
+ return created
678
+ // rawKey 仅本次返回;通过安全渠道展示一次,不写日志。
679
+ return created
680
+ }
681
+
682
+ async function apiKeyLogin(rawKey: string) {
683
+ // 通常由客户端取得 Key 后调用;服务器内部验证并绑定 Key。
684
+ return iam.auth.loginWithApiKey({ key: rawKey })
685
+ }
686
+
687
+ async function requireRecordRead(accessToken: string, deniedMessage: string) {
688
+ // deniedMessage 由应用 i18n 提供;先读取当前且已收敛 scope 的会话。
689
+ const verified = await iam.auth.verifyToken(accessToken)
690
+ if (!verified.success)
691
+ return verified
692
+ const allowed = verified.data.permissions.some(code =>
693
+ code === '*' || code === 'record:read'
694
+ || (code.endsWith(':*') && 'record:read'.startsWith(code.slice(0, -1))),
695
+ )
696
+ if (!allowed)
697
+ return err(HaiCommonError.FORBIDDEN, deniedMessage)
698
+ // 这里只确认操作权限;业务层还须检查具体记录的数据归属。
699
+ return ok({ userId: verified.data.userId })
700
+ }
701
+
702
+ async function listMyKeys(userId: string) {
703
+ return iam.apiKey.listApiKeys(userId, {
704
+ page: 1,
705
+ pageSize: 20,
706
+ search: 'Read',
707
+ enabled: true,
708
+ })
709
+ }
710
+
711
+ async function revokeKey(keyId: string) {
712
+ // 调用前校验本人归属或管理权限。
713
+ return iam.apiKey.revokeApiKey(keyId)
714
+ }
715
+ ```
716
+
717
+ Only `rawKey` can be used for Key login. `apiKey.id` is a management ID and `keyPrefix` is for display/search. `verifyApiKey(rawKey)` validates Key metadata only; it does not replace user-status, scope or operation authorization.
718
+
719
+ Key-derived sessions remain bound to the Key when refreshed through `iam.session.refresh`. Successful revocation invalidates existing accessToken get/verify operations and refreshToken refresh operations. Expiration, disabling or persistent-read failures also reject access. Current role changes are enforced through `iam.auth.verifyToken`. There is no scope-update API; create a replacement Key and revoke the old one.
720
+
721
+ `expirationDays: 0` means no expiration; omission uses `apikey.defaultExpirationDays`. Set an explicit duration for time-limited access. Never put raw Keys or Tokens in URLs, logs, public configuration or ordinary localStorage.
722
+
723
+ ##### Considerations
724
+
725
+ - **No plaintext persistence**: only hashes are stored; `rawKey` at creation is the sole opportunity to retrieve it.
726
+ - **Prefix lookup**: `keyPrefix` narrows candidates without a full table scan.
727
+ - **Count limit**: at most `maxKeysPerUser` (default 10) keys per user.
728
+ - **Explicit enablement**: API keys initialize only with `login.apikey: true`.
729
+
730
+ ---
731
+
732
+ ### More usage
733
+
734
+ See the skill template (Chinese) for detailed API parameters, codes, and integration patterns: `packages/cli/templates/skills/hai-iam/SKILL.md`.
735
+
736
+ ### Frontend API calls
737
+
738
+ Public HTTP APIs are defined by `@h-ai/api-contract` and called through the `@h-ai/api-client` typed client:
739
+
740
+ ```ts
741
+ import { apiClient } from '@h-ai/api-client'
742
+
743
+ await apiClient.init({ baseUrl: '/api/v1' })
744
+
745
+ // 登录
746
+ const result = await apiClient.iam.auth.login({
747
+ identifier: 'admin',
748
+ password: 'Password123',
749
+ })
750
+
751
+ // 获取当前用户
752
+ const user = await apiClient.iam.auth.currentUser()
753
+
754
+ // 修改密码
755
+ await apiClient.iam.auth.changePassword({
756
+ oldPassword: 'Password123',
757
+ newPassword: 'NewPassword456',
758
+ })
759
+ ```
760
+
761
+ The browser entry exports configuration schemas, types and HaiIamError, without the server `iam` instance. Authenticate through the HTTP/typed client.
762
+
763
+ ### Password reset
764
+
765
+ Password reset requires `passwordReset.enabled` and a working delivery callback/Reach template. Success does not prove that the account exists or the recipient received a message. Handle delivery/internal failures through HaiResult. Reset tokens expire, limit attempts, and permit one successful consumption; success revokes old sessions. Administrator resets do not require the old password, but applications must authorize the administrator.
766
+
767
+ ```ts
768
+ import { iam } from '@h-ai/iam'
769
+
770
+ async function requestReset(identifier: string) {
771
+ // 缺失用户与有效用户使用相同成功响应,避免枚举账户。
772
+ return iam.user.requestPasswordReset(identifier)
773
+ }
774
+
775
+ async function confirmReset(token: string, newPassword: string) {
776
+ // token 来自通知中的重置链接;成功后重新登录。
777
+ return iam.user.confirmPasswordReset(token, newPassword)
778
+ }
779
+
780
+ async function resetByAdministrator(userId: string, newPassword: string) {
781
+ // 管理权限校验在服务端入口完成;不接受未经校验的 userId。
782
+ return iam.user.adminResetPassword(userId, newPassword)
783
+ }
784
+ ```
785
+
786
+ Cache cleanup may return a failure after the new password and credential generation have committed; they are not rolled back. Sign in again instead of automatically retrying with old credentials.
787
+
788
+ ### One-time tickets — `iam.ticket`
789
+
790
+ Short-lived capability tickets for channels that cannot carry Bearer tokens, such as WebSocket URLs. An authenticated HTTP request issues a ticket bound to a subject, purpose, and operation. The server **atomically consumes** it on first validation, making it valid only once.
791
+
792
+ ```ts
793
+ import type { Session } from '@h-ai/iam'
794
+ import { iam } from '@h-ai/iam'
795
+
796
+ async function issueAudioTicket(session: Session) {
797
+ // session 已通过当前授权校验,签发前还须验证该操作及资源范围。
798
+ const issued = await iam.ticket.issue({
799
+ subjectId: session.userId,
800
+ purpose: 'ai-audio',
801
+ grant: { operation: 'transcribe', model: 'whisper-1' },
802
+ ttlMs: 30_000,
803
+ })
804
+ if (!issued.success)
805
+ return issued
806
+ // 仅将短期票据交给客户端,不把长期 accessToken 放进 URL。
807
+ return issued
808
+ }
809
+
810
+ async function consumeAudioTicket(ticket: string) {
811
+ const consumed = await iam.ticket.consume(ticket, { purpose: 'ai-audio' })
812
+ if (!consumed.success)
813
+ return consumed
814
+ // 按 grant 校验本次建连操作/资源;票据本身不自动读取当前 RBAC。
815
+ return consumed
816
+ }
817
+ ```
818
+
819
+ Features: cryptographically secure randomness, TTL, atomic single use (only one concurrent consumer succeeds), and purpose binding to prevent cross-purpose replay.
820
+
821
+ ### Configuration driven authentication
822
+
823
+ Password login submits only `identifier` and `password`; `AuthPanel` maps the `LoginForm` field `username` to `identifier`. Server settings `session.maxAge` / `session.refreshTokenMaxAge` control session lifetimes. The login form has no per-login persistent-session option.
824
+
825
+ `iam.publicConfig` and the client's `client.iam.auth.publicConfig()` HTTP contract expose a safe configuration whitelist and only advertise configured, available methods. `login.password/otp/ldap`, `otp.email/sms`, `register.enabled/defaultAccountStatus`, `passwordReset.enabled/mode/resetUrl` and agreement display flags are independent. `ui` configures card/split layout, title, subtitle, brand text, Logo and illustration URLs. Browser links only accept HTTP(S). LDAP is optional and disabled by default; install ldapts only for the default client, or inject a custom factory.
826
+
827
+ When using optional Reach, initialize it after reldb/cache and call `iam.init({ ...settings, reach })`. Standard `otp_email`, `otp_sms`, `password_reset` and `password_reset_sms` templates supply notification callbacks. Explicit callbacks override these defaults. Configure a real SMTP, SMS or HTTP provider for delivery; Console records metadata only. Delivery failures return errors.
828
+
829
+ SvelteKit routes export handlers from `kit.auth.createApi(iam)`; use `kit.auth.publicPaths` for anonymous endpoints and `kit.client.createAuthActions()` for Cookie based form actions. HTTP clients use `apiClient.createAuthActions(client)`, which saves successful authentication tokens through the configured storage. Pass actions and config to `AuthPanel` for login, registration and password recovery. Individual `LoginForm` components support `methods/onsendotp/resendInterval`; password forms accept the full `passwordPolicy`. Administrators use permission-guarded `iam.user.createUser()`; disabling public registration does not disable administrator creation.
830
+
831
+ Agreements open in a Modal without losing form input. URL pages must allow iframe embedding; scripts are disabled and a new-window fallback is available. LoginForm/RegisterForm expose `header/extraFields/footer` snippets. AuthPanel forwards `loginHeader/loginFields/loginFooter` and `registerHeader/registerFields/registerFooter`; application state and actions handle additional fields. See [UI usage](../ui/README.en.md#configuration-driven-authentication).
832
+
833
+ All nodes share the same database and Redis; Memory is for single-process tests. OTP code, attempts and cooldown share one identity key with CAS publication and consumption. Conflicts reject stale generations and consumption preserves cooldown. Password reset hash, expiry, attempts and resetVersion live in the user database row. Policy failures count, and one conditional SQL statement changes the password, consumes the token and advances credentialVersion. New tokens, later attempts, disablement or expiry reject delayed commits. Requesting a reset only advances resetVersion; successful password changes advance credentialVersion. Authentication captures the verified credential generation; session creation, authentication and refresh check current state via shared generation cache or safe DB fallback, so failed cache cleanup cannot authorize old sessions. Notification callbacks run after publication and receive { expiresAt, idempotencyKey } as their third argument. Applications and Reach providers must durably deduplicate that key. Exceptions/timeouts mean delivery may have succeeded: the result is unknown, with no automatic resend or rollback. Exactly-once external effects require receiver-side idempotency.
834
+
835
+ Standard endpoints: `GET /auth/config`, `POST /auth/login/ldap`, `POST /auth/password-reset/request`, `POST /auth/password-reset/confirm`. Security errors: `SECURITY_BUSY (hai:iam:023)` and `PASSWORD_RESET_DISABLED (hai:iam:024)`. Declare optional fields such as `ldap` in YAML before overriding them, or replace the module configuration with a JSON value in `HAI_IAM`. Existing fields support overrides such as `HAI_IAM_LOGIN_OTP`.
836
+
837
+ `passwordReset.mode` selects `email` (default, `password_reset`) or `phone` (`password_reset_sms`); the public config tells the recovery form which identifier to request.
838
+
839
+ #### Markdown agreements
840
+
841
+ Configure `agreements.userAgreementMarkdown` and `privacyPolicyMarkdown` to load agreement content directly from configuration. YAML `|` preserves paragraphs and newlines. The modal reuses the safe MarkdownRenderer with raw HTML, scripts and code tools disabled. Markdown takes priority over a URL for the same agreement; a URL remains an optional external-view link. URL-only agreements use a sandboxed iframe.
842
+
843
+ ```yaml
844
+ agreements:
845
+ showOnRegister: true
846
+ showOnLogin: false
847
+ userAgreementMarkdown: |
848
+ # 用户协议
849
+
850
+ **账户使用条款**
851
+
852
+ - 请妥善保管账户凭据。
853
+ privacyPolicyMarkdown: |
854
+ # 隐私政策
855
+
856
+ 注册资料用于账户管理,会话信息用于身份认证。
857
+ # 可选 URL;同主题同时配置 Markdown 和 URL 时,弹窗优先显示 Markdown。
858
+ # userAgreementUrl: https://example.com/terms
859
+ # privacyPolicyUrl: https://example.com/privacy
860
+ ```
861
+
862
+ ### Error handling
863
+
864
+ All operations return `HaiResult<T>`; branch on `HaiIamError`:
865
+
866
+ ```ts
867
+ import { HaiIamError, iam } from '@h-ai/iam'
868
+
869
+ const result = await iam.auth.login({ identifier: 'admin', password: 'wrong' })
870
+ if (!result.success) {
871
+ if (result.error.code === HaiIamError.INVALID_CREDENTIALS.code) {
872
+ // 用户名或密码错误
873
+ }
874
+ }
875
+ ```
876
+
877
+ ### Optional integrations
878
+
879
+ Role lists aggregate actual member counts for the current page with one `iam.authz.getRoleUserCounts(roleIds)` call, returning `HaiResult<Map<string, number>>`. Unassigned roles get 0; failed queries show loading errors rather than fabricated zero counts.
880
+
881
+ Sessions and refresh consumption share one versioned `hai:iam:sessions:{SHA256(userId)}` document, replacing separate mappings/indexes. Bearer tokens contain an untrusted routing hint plus 256 random bits; clients must treat them as opaque strings. Rotation checks `Session.version` at commit. Memory is for single-process tests; production nodes share Redis, including Cluster. Conflicts retry from the latest state and return CONFLICT when exhausted; one refresh cannot publish two successors. An ambiguous lost response requires failing closed and signing in again, without assuming the old token remains valid. All records remain covered by the same user index.
882
+
883
+ `iam.session.update(token, patch, expectedVersion)` requires the Session.version from the read. Stale versions return CONFLICT; revoked tokens return SESSION_NOT_FOUND. Timestamp-only sliding updates keep the content version, allowing normal parallel requests. Batch permission updates bind the initial token/version set and never apply delayed snapshots to later sign-ins; newer content versions require re-reading authorization before retry.
884
+
885
+ ### Shared authorization cache
886
+
887
+ Redis caches nonsecret account/credential state, role/permission summaries, sensitive operations and verified API Key DTOs for 60 seconds. Cache values omit database password/Key verification hashes, raw keys and reset tokens; API Key cache addresses use a one-way SHA-256 fingerprint of the raw Key. Account/password/Key expiry is evaluated against current time on every check; authorization cold reads use one JOIN. IAM writers establish a shared dirty marker before DB mutation and advance the epoch after commit/rollback, including caller-owned transactions. A racing cold read cannot publish an obsolete cache entry. Normal hot checks avoid PG; writes, unconfirmed invalidation or cache read failure use the database. Failure of the session Redis store still rejects authentication.
888
+
889
+ Failure to register invalidation rejects a write before mutation. Failed invalidation after commit preserves the successful mutation and persistent dirty marker, so later checks keep using DB. Crashed writers or unknown commit results never expire that marker by TTL; alert and repair cache state in a maintenance window after confirming no IAM write transaction remains. All nodes must upgrade together and write via IAM APIs. Raw SQL, old writers and independent table edits require quiesced writes and full IAM cache invalidation. Verified raw Key cache hits avoid repeated expensive hashes; lastUsedAt is sampled during cold verification, with up to about one minute lag.
890
+
891
+ ## Self-hosted interactive challenges
892
+
893
+ captcha.enabled defaults to false. Enable self-hosted ALTCHA proof of work with ttlSeconds (default120, 1–600 seconds), cost (default5000, 100–50000) and maxCounter (default100, 1–2000). No external CAPTCHA account is needed. HMAC secrets and one-use state remain in the existing shared Cache; production still requires a shared backend. Computational challenges increase automation cost and do not guarantee a human requester.
894
+
895
+ `iam.auth.createCaptcha({operation})` returns {id,challenge}, for login/register/sendOtp/passwordReset/renewPassword. Solve using official altcha/lib solveChallenge/pbkdf2 and pass JSON.stringify({id,solution}) as captcha. Password/OTP/LDAP login, public registration and anonymous password renewal enforce it on the server. sendOtp(identifier,{captcha}) and requestPasswordReset(identifier,{captcha}) accept an optional second argument. Proofs are purpose-bound, short-lived and atomically consumed before authentication; wrong passwords also consume them. Cache failure never bypasses validation. Cache TTL controls expiry and reinitialization does not extend it. Managed creation/invitations, authenticated password changes and API Key machine authentication retain their own authorization and credentials.
896
+
897
+ Standard Kit/Serv expose POST /auth/captcha (Kit same-origin /api prefix) and preserve captcha. iam.publicConfig.captcha exposes only enabled. AuthPanel/createAuthActions automatically create, asynchronously solve and submit a fresh proof when enabled, and do not load the solver when disabled. Custom forms can reuse the protocol. Enabled UI configuration without createCaptcha fails closed; cancellation/unmount stops subsequent login. Proofs are always redacted as credentials and are not persisted in browser storage. Errors030/031/032/033 mean required, invalid/consumed/expired, disabled and unavailable respectively.
898
+
899
+ ## Optional password and login protection
900
+
901
+ Public login and `renewPassword` return `INVALID_CREDENTIALS` (“Username or password is incorrect.”) for missing, passwordless, dormant, closed, expired and locked accounts. Restricted accounts do not verify further password guesses: correct and incorrect passwords receive the same response without changing the existing lock counters. Authenticated administration can still inspect the account state. Password computation or storage faults remain infrastructure failures and do not count as incorrect passwords.
902
+
903
+ Reusing the current password returns `HaiIamError.PASSWORD_REUSE_NOT_ALLOWED` (`hai:iam:034`); strength failures remain `PASSWORD_POLICY_VIOLATION` (`008`). Map codes to friendly UI messages and hide raw unknown exceptions. Failed initial renewal never issues an ordinary session; sign in with the new password after success.
904
+
905
+ `password.minLength/maxLength` and individual `require*` flags control strength. `minCharacterClasses` accepts any N of uppercase, lowercase, digits and special characters; 0 adds no count requirement. CLI/admin examples default to 8–128 characters with no extra class count; set `minCharacterClasses: 3` to require any three classes. All server creation/change/reset paths share validation.
906
+
907
+ `expirationDays: 0` disables expiry; positive values are days. `preventSamePassword` is independent. Expiry/initial-change policies reject the current password. `requireInitialChange` applies to administrator-created/reset credentials, rather than self-chosen registration passwords. Initial/expired credentials cannot authorize sessions or refresh existing ones. `iam.user.renewPassword({ identifier, password, newPassword })` verifies the old password and requires fresh login; Kit/Serv and AuthPanel provide the completion flow.
908
+
909
+ `security.lockoutEnabled` defaults to true in the framework Schema; simplified CLI/admin examples explicitly disable it until enabled by the project. `failureWindow` defaults to 86400 seconds from the first failure, `maxLoginAttempts` is the threshold, and `lockoutDuration` is seconds locked. Examples use 1200 seconds. Disabling ignores historical locks. Shared database updates atomically enforce counts/thresholds.
910
+
911
+ `initialCredentials.enabled` enables `iam.user.inviteUser()`: private random initial secrets are never returned or sent; only short-lived single-use setup invitations are delivered. This reuses passwordReset mode, expiry, attempt limits and sensitive delivery. Missing delivery/disabled reset prevents creation. Unconfirmed delivery returns failure while retaining the account/invitation; verify delivery before requesting another reset notification.
912
+
913
+ `loginAccess.enabled` enables restrictions. `allowIps/denyIps` accept IPv4/IPv6/CIDR with deny precedence; empty lists leave that dimension unrestricted. `windows` use `days` (Monday1–Sunday7), `start/end` (HH:mm, inclusive start/exclusive end, overnight supported), and IANA `timezone`. Login methods take trusted `AuthRequestContext` as their second argument: Kit uses platform getClientAddress(), Serv uses context.ip resolved through trusted proxies. Never copy arbitrary forwarding headers or credential JSON addresses. Address restrictions fail closed without a trusted address and cover automatic registration login/API Key login.
914
+
915
+ `reauthentication.enabled/operations` select sensitive operations; `ttlSeconds` bounds proof lifetime. `reauthenticate(token, password, operation)` issues a single-use proof; authorized handlers call `checkSensitiveOperation(token, operation, ticket)` after permission/resource checks. Proofs bind subject/session/operation and grant no extra permissions. Serv IAM writes/admin guards read `x-hai-reauthentication`; Kit business handlers use `kit.guard.requireSensitive(iam, event, operation)` with project-defined IDs. Keep proof tickets briefly in memory. Public configuration exposes switches/operation IDs only. In the standard admin/fullstack UI, an already signed-in user who selects a sensitive action confirms their current account password in a dialog. Cancel does not submit, an incorrect password stops the action, and success continues the original action without creating another login session.
916
+
917
+ RBAC permissions accept `sensitiveOperations: string[]`, persisted as nullable `sensitive_operations`. Trusted management code can call `iam.authz.setPermissionSensitiveOperations(permissionId, operations)` and must check `success`. An empty array removes only that permission's additions. With `reauthentication.enabled`, rules from all permission records are combined with file `operations` and enforced live. IDs must match actual business guards; they grant no permissions. Async `iam.getPublicConfig()` returns the combined public policy and fails on database read errors; synchronous `iam.publicConfig` retains the file snapshot only. Disabling RBAC ignores database rules. Migration adds a nullable JSON column while preserving authorization relationships.
918
+
919
+ ## Account type and lifecycle
920
+
921
+ Users default to `accountType: 'permanent'` and `accountStatus: 'active'`. Administrative code can create a `temporary` account with `accountExpiresAt`, or use `updateUser` to set `dormant` or `closed`. State and credential generation change in one transaction. Expired or inactive accounts are rejected by every login method, access verification, and refresh. Explicit reactivation requires a new login; old tokens do not revive. `accountStatus` is the sole lifecycle field; there is no separate user enable/disable switch. `closed` retains the record; `deleteUser` still physically removes it, while the application owns personal-data retention and cancellation workflows.
922
+
923
+ Before beta.1, stop older writers and back up the database. Migration v6 converts an old false/NULL `enabled` value with active status to dormant, retains dormant/closed states, invalidates affected credentials, and drops the column. User inputs, filters and sorting now use only `accountStatus`. An image rollback does not restore the old schema; restoring an older version also requires a verified database backup. Authorized administrators can reset passwords in any account state without changing that state. `password.requireInitialChange` controls the subsequent password-change requirement.
924
+
925
+ `accounts.dormantAfterDays` defaults to `0`, disabling automatic dormancy. With `90`, IAM scans active accounts with no successful login in 90 days at `accounts.scanIntervalSeconds` intervals (3600 seconds by default); accounts never used start at creation. Only successful authentication updates `lastLoginAt`. Conditional database updates make repeated scans across nodes idempotent. Trusted code or an existing scheduler may also call `iam.user.scanInactiveAccounts()`. `close()` stops the timer and waits for its in-flight scan. Public registration must not expose administrative lifecycle fields.
926
+
927
+ ## Optional session security policies
928
+
929
+ Set `session.idleTimeout: 1800` to require a new login after 30 minutes without a successful authenticated request; `0` disables it. Access reads, activity commits, and refresh rotation all enforce this limit. A long-lived refresh token cannot revive an idle session. Automatic refresh does not extend the idle deadline. Background business polling also counts as authenticated activity, so applications must choose polling behavior that matches their user activity policy.
930
+
931
+ `absoluteTimeout` limits seconds since the original login and does not restart on refresh; `0` disables it. `maxSessions` limits active sessions per user, including expired access tokens that can still refresh; `0` means unlimited. Concurrent logins check the quota in the same shared-state CAS. `singleDevice: true` replaces the previous session by default; `singleDeviceMode: 'reject'` instead keeps it and rejects a duplicate login. Explicit logout revokes both access and refresh; closing a browser cannot replace server-enforced expiry.
932
+
933
+ When these deadlines are disabled, access and refresh retain their independent lifetimes. Every successful login creates new random credentials. `SESSION_LIMIT_EXCEEDED` reports the configured session quota.
934
+
935
+ ## Schema and credential notifications
936
+
937
+ After RelDB initialization, `migrateIam({ dryRun: true })` previews changes without writes. iam.init upgrades supported credential/reset columns, retaining users/passwords and permissions. Back up and stop old writers first. Legacy sessions without credential versions must sign in again; never roll back credential generations to restore authorization. Default Reach notifications use sensitive/expiresAt/idempotencyKey without plaintext persistence or delayed replay. Custom callbacks must enforce the same expiry and credential protection.
938
+
939
+ When audit is initialized and capture.enabled is on, IAM records important account, authentication, password, authorization, API Key and session events, including failed/denied outcomes and safe reason codes. Initialize audit before IAM and close in reverse order. Passwords, one-time proofs and tokens are never copied into events. Only successful login establishes an actor; trusted service/CLI callers supply actor/correlation through audit.withContext. Events inside an external authorization transaction commit or roll back with that transaction; record failed attempts after rollback at the caller boundary. Audit failures emit diagnostics without converting an applied business result into an unsafe retry.
940
+
941
+ `session.terminalLimits: { web: 2, app: 1 }` limits sessions per terminal type; `{}` disables this policy. Unlisted types are rejected when enabled, and older sessions count as `web`. `terminalLimitMode` defaults to `reject`; `replace` evicts only the oldest sessions of the same type. Global `maxSessions` and `singleDevice` still apply in the same atomic user CAS. Server login context `terminalType` and standard client `auth.terminalType` select a group; this is not hardware identification or authorization.
942
+
943
+ For in-place recovery, use UI `createSessionRecovery` / `SessionRecoveryDialog` with SDK `recoverSession`. Normal password/OTP/LDAP login policies still apply; `expectedUserId` restricts session issuance to the original principal. Only an explicitly authentication-rejected request may be retried once. Ambiguous network failures are not replayed. In-memory drafts lost through document reload/closure cannot be restored.
944
+
945
+ ## Page exit session policy
946
+
947
+ `session.endOnPageHide` defaults to `false`. Hosts call `actions.bindPageExit({ enabled: config.session.endOnPageHide })` and invoke its returned cleanup on unmount; standard admin/H5/fullstack templates already bind it. A non-BFCache `pagehide` (including reload and full document navigation) makes a best effort POST to `/auth/session/close`, revoking the held access and related refresh through the existing CSRF/encrypted transport with `keepalive` and no automatic refresh. This response does not clear cookies/local credentials, avoiding a late old response overwriting a new login. Explicit logout still revokes and clears current credentials normally. Browsers may omit page exit requests; configure server `idleTimeout`/`absoluteTimeout` as appropriate. Immediate revocation on browser close is not guaranteed.
948
+
949
+ The standard `/auth/me` endpoint also checks `iam.profile.update` / `iam.profile.avatar`. Mixed updates prefer an enabled profile rule, otherwise the avatar rule. `authz.getSensitiveOperations(operation?)` can query one operation; omit it to list database rules. Super-administrator identity is read from shared roles on every check. Default seeding rereads concurrent unique conflicts and propagates permission-assignment failures.
950
+
951
+ ## Development and testing
952
+
953
+ Public examples import from `@h-ai/iam` and distinguish defaultable `IamConfigInput` from parsed `IamConfig`. Initialize server dependencies, check `HaiResult.success`, update sessions with their observed `version`, and reuse persistent notification idempotency keys. Strategy/repository examples marked as internal context do not define application deep imports. The checker verifies types only; Redis/database, LDAP and delivery behavior still require their integration tests. See the [JSDoc conventions (Chinese)](../../.agents/instructions/module-conventions.md#jsdoc-与示例验证).
954
+
955
+ ```bash
956
+ pnpm --filter @h-ai/iam typecheck
957
+ pnpm --filter @h-ai/iam lint
958
+ pnpm --filter @h-ai/iam build
959
+ pnpm --filter @h-ai/iam test
960
+ node scripts/check-module-jsdoc.mjs --package iam
961
+ ```
962
+
963
+ ## License
964
+
965
+ Apache-2.0