@h-ai/iam 0.1.0-alpha.66 → 0.1.0-alpha.68

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 CHANGED
@@ -4,9 +4,11 @@
4
4
 
5
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
6
 
7
+ ## Introduction
8
+
7
9
  Identity and access management through a unified `iam` object for authentication, sessions, authorization, and user management.
8
10
 
9
- ## Features
11
+ ## Core features
10
12
 
11
13
  | Feature | Description |
12
14
  | -------------------- | ---------------------------------------------------------------------------------------------------------------------- |
@@ -18,27 +20,29 @@ Identity and access management through a unified `iam` object for authentication
18
20
  | **One-time tickets** | Short-lived capability tickets (issue / atomic consume) for channels such as WebSocket that cannot carry Bearer tokens |
19
21
  | **Frontend client** | HTTP API contracts for independent frontends |
20
22
 
21
- ## Installation
23
+ ## Getting started
24
+
25
+ ### Installation
22
26
 
23
27
  ```bash
24
28
  pnpm add @h-ai/iam
25
29
  ```
26
30
 
27
- ## Dependencies
31
+ ### Dependencies
28
32
 
29
33
  - `@h-ai/reldb`: persistence for users/roles/permissions; **initialize before iam.init()**.
30
- - `@h-ai/cache`: sessions/OTP/reset tokens/one-time tickets/shared locks; **initialize before iam.init()**.
34
+ - `@h-ai/cache`: sessions/OTP/one-time tickets; **initialize before iam.init()**.
31
35
  - `@h-ai/crypto`: password hashing, used internally and initialized automatically.
32
36
  - `@h-ai/audit`: audit records for key RBAC operations.
33
37
 
34
38
  - `@h-ai/reach`: **optional**, installed, initialized and injected by the application for standard notifications; custom callbacks do not require Reach.
35
39
  - `ldapts`: **optional**, loaded only when LDAP is explicitly enabled without an injected `ldapClientFactory`.
36
40
 
37
- ## Optional LDAP and notifications
41
+ ### Optional LDAP and notifications
38
42
 
39
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.
40
44
 
41
- ### LDAP / Active Directory
45
+ #### LDAP / Active Directory
42
46
 
43
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.
44
48
 
@@ -58,7 +62,7 @@ ldap:
58
62
  connectTimeout: 5000
59
63
  ```
60
64
 
61
- `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. Set `ldapSyncUser: false` or inject a `ldapClientFactory` returning `bind/search/unbind` methods with HaiResult results. An injected factory does not require ldapts.
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 enabled 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.
62
66
 
63
67
  ```ts
64
68
  import type { IamConfigSettingsInput } from '@h-ai/iam'
@@ -75,7 +79,7 @@ if (!result.success)
75
79
  return result
76
80
  ```
77
81
 
78
- ### Automatic delivery with optional Reach
82
+ #### Automatic delivery with optional Reach
79
83
 
80
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.
81
85
 
@@ -133,7 +137,7 @@ if (!initialized.success)
133
137
  return iam.auth.sendOtp('user@example.com')
134
138
  ```
135
139
 
136
- ### Custom email and SMS delivery
140
+ #### Custom email and SMS delivery
137
141
 
138
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.
139
143
 
@@ -145,26 +149,26 @@ import { mailService, smsService } from './notifications.js'
145
149
  // 调用方须检查服务投递结果,失败时拒绝 Promise,不能吞掉错误。
146
150
  const initialized = await iam.init({
147
151
  otp: { email: true, sms: true },
148
- onOtpSendEmail: async (email, code) => {
149
- await mailService.send({ to: email, subject: 'Verification code', text: code })
152
+ onOtpSendEmail: async (email, code, context) => {
153
+ await mailService.send({ to: email, subject: 'Verification code', text: code, idempotencyKey: context.idempotencyKey })
150
154
  },
151
- onOtpSendSms: async (phone, code) => {
152
- await smsService.send({ to: phone, code })
155
+ onOtpSendSms: async (phone, code, context) => {
156
+ await smsService.send({ to: phone, code, idempotencyKey: context.idempotencyKey })
153
157
  },
154
158
  passwordReset: { mode: 'email' },
155
- onPasswordResetRequest: async (user, token, expiresAt) => {
159
+ onPasswordResetRequest: async (user, token, context) => {
156
160
  if (!user.email)
157
161
  throw new Error('Reset recipient unavailable')
158
162
  const url = new URL('https://app.example.com/auth/reset-password')
159
163
  url.searchParams.set('token', token)
160
- await mailService.send({ to: user.email, subject: 'Password reset', text: url.toString(), expiresAt })
164
+ await mailService.send({ to: user.email, subject: 'Password reset', text: url.toString(), expiresAt: context.expiresAt, idempotencyKey: context.idempotencyKey })
161
165
  },
162
166
  })
163
167
  if (!initialized.success)
164
168
  return initialized
165
169
  ```
166
170
 
167
- ## Quick start
171
+ ### Quick start
168
172
 
169
173
  ```ts
170
174
  import { cache } from '@h-ai/cache'
@@ -209,9 +213,9 @@ LDAP is disabled by default. Public OTP methods require `otp` settings and send
209
213
 
210
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.
211
215
 
212
- ## Design
216
+ ### Design
213
217
 
214
- ### Architecture
218
+ #### Architecture
215
219
 
216
220
  IAM is a **lifecycle singleton** managed through `iam.init()` / `iam.close()`. Five internal subsystems initialize in dependency order:
217
221
 
@@ -228,29 +232,29 @@ All subsystems use **factories + closures** (for example `createSessionOperation
228
232
 
229
233
  Before initialization, every subsystem method returns `NOT_INITIALIZED` through a `NotInitializedKit` proxy.
230
234
 
231
- ### Data storage
235
+ #### Data storage
232
236
 
233
- | Data | Storage | Description |
234
- | --------------------------------------------------- | -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
235
- | 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` |
236
- | Sessions (accessToken → Session) | Cache | TTL, optional sliding renewal |
237
- | Token mapping (refreshToken → userId + accessToken) | Cache | Separate keys; old tokens expire immediately after refresh |
238
- | OTP codes | Cache | TTL + attempt limit |
239
- | Password reset tokens | Cache | TTL + maximum verification attempts |
240
- | API Key | DB | `hai_iam_api_keys`; store hashes only, never plaintext; prefix narrows candidates |
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 |
241
245
 
242
246
  ---
243
247
 
244
- ### Authentication (authn) — `iam.auth`
248
+ #### Authentication (authn) — `iam.auth`
245
249
 
246
- #### Key types
250
+ ##### Key types
247
251
 
248
252
  - **`AuthnOperations`**: `login` / `loginWithOtp` / `loginWithLdap` / `loginWithApiKey` / `logout` / `verifyToken` / `sendOtp` / `registerAndLogin`.
249
253
  - **`AuthStrategy`**: strategy interface (`type` + `authenticate` + `challenge?`), implemented for password, OTP, LDAP, and API Key.
250
254
  - **`AuthResult`**: login result with user details, `TokenPair` (accessToken + refreshToken), and roles/permissions.
251
255
  - **`Credentials`**: a credential union combining `{ type: 'password' | 'otp' | 'ldap' | 'apikey' }` with strategy-specific credentials.
252
256
 
253
- #### Key workflows
257
+ ##### Key workflows
254
258
 
255
259
  **Password login:**
256
260
 
@@ -274,13 +278,14 @@ login(credentials)
274
278
  ```
275
279
  verifyToken(accessToken)
276
280
  → session.get(accessToken) → cache lookup
281
+ → Read the persistent user and validate enabled status and the session-bound credentialVersion
277
282
  → Extend TTL automatically when sliding renewal is enabled
278
- → Read current user status, role JOIN and one batched permission query (skip permissions for no roles)
279
- → Reject disabled users or database failures; intersect API Key scopes again
283
+ → Read the current user again, recheck enabled status and credentialVersion, then read the role JOIN and batched permissions (skip permissions for no roles)
284
+ → Reject any persistent read failure, missing/disabled user or credential-version mismatch; intersect API Key scopes again
280
285
  → Return current Session (userId, roles, permissions)
281
286
  ```
282
287
 
283
- #### Usage
288
+ ##### Usage
284
289
 
285
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.
286
291
 
@@ -312,47 +317,47 @@ async function registerWithSession(username: string, password: string) {
312
317
  }
313
318
  ```
314
319
 
315
- #### Considerations
320
+ ##### Considerations
316
321
 
317
322
  - **Timing attack mitigation**: perform a dummy password hash for nonexistent users to keep response timing consistent.
318
323
  - **Account lockout**: lock for `lockoutDuration` seconds after `maxLoginAttempts` consecutive failures.
319
324
  - **Independent strategy toggles**: `login.password` / `login.otp` / `login.ldap` / `login.apikey`.
320
325
  - **OTP security**: rejection sampling avoids modulo bias, constant-time comparison mitigates timing attacks, and `resendInterval` limits frequency.
321
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
+
322
329
  ---
323
330
 
324
- ### Sessions (session) — `iam.session`
331
+ #### Sessions (session) — `iam.session`
325
332
 
326
- #### Key types
333
+ ##### Key types
327
334
 
328
335
  - **`SessionOperations`**: `create` / `get` / `verifyToken` / `update` / `delete` / `deleteByUserId` / `refresh` / `revokeRefresh` / `patchUserSessions`.
329
336
  - **`Session`**: userId, roles, permissions, accessToken, expiration, and other session data.
330
337
  - **`TokenPair`**: `{ accessToken, refreshToken, expiresIn, tokenType: 'Bearer' }`.
331
338
 
332
- #### Key workflows
339
+ ##### Key workflows
333
340
 
334
341
  **Token refresh (rotation):**
335
342
 
336
343
  ```
337
344
  refresh(refreshToken)
338
- → Read refreshToken → { userId, accessToken } from cache
339
- → Get old session via accessToken
340
- → Delete old refreshToken (expire key immediately to prevent replay)
341
- → Delete old accessToken session
342
- → Create a new session with the old user context
343
- → Return new TokenPair
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
344
349
  ```
345
350
 
346
351
  **Single-device login:**
347
352
 
348
353
  ```
349
354
  create(options) [singleDevice=true]
350
- → Clear all existing user tokens via user→tokens mapping
351
- → Generate new accessToken + refreshToken
352
- → Store session in cache
355
+ → Build candidate accessToken + refreshToken
356
+ → Single-key CAS replaces all old sessions and their index together
357
+ → Failed commits preserve the previous session
353
358
  ```
354
359
 
355
- #### Usage
360
+ ##### Usage
356
361
 
357
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.
358
363
 
@@ -387,27 +392,35 @@ async function forceSignOut(userId: string) {
387
392
  }
388
393
  ```
389
394
 
390
- #### Considerations
395
+ ##### Considerations
391
396
 
392
397
  - **Cache-only storage**: Memory sessions are lost on restart; shared Redis sessions survive IAM service restarts until TTL or revocation.
393
398
  - **Sliding renewal**: with `sliding: true`, each `get()` extends TTL automatically.
394
399
  - **Refresh rotation**: old refreshToken expires after one use; replay detection indicates token leakage.
395
400
  - **Token strength**: 256-bit cryptographic randomness (`crypto.getRandomValues(new Uint8Array(32))`), base64url encoded.
396
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 enabled local user and binds its current generation; it cannot create an authenticated identity without a database record.
409
+
397
410
  ---
398
411
 
399
- ### Authorization (authz) — `iam.authz`
412
+ #### Authorization (authz) — `iam.authz`
400
413
 
401
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.
402
415
 
403
- #### Key types
416
+ ##### Key types
404
417
 
405
418
  - **`AuthzOperations`**: role/permission CRUD, role-permission/user-role assignments, and permission checks.
406
419
  - **`Role`**: `{ id, code, name, description, isSystem }`.
407
420
  - **`Permission`**: `{ id, code, name, type, resource, action }`; type is `menu` / `api` / `button`.
408
421
  - **`PermissionType`**: permission type enum.
409
422
 
410
- #### Key workflows
423
+ ##### Key workflows
411
424
 
412
425
  **Permission checks (`checkPermission`):**
413
426
 
@@ -430,7 +443,7 @@ assignPermissionToRole(roleId, permId)
430
443
  (best-effort: failures only log.error; permission assignment still succeeds)
431
444
  ```
432
445
 
433
- #### Usage
446
+ ##### Usage
434
447
 
435
448
  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.
436
449
 
@@ -478,19 +491,19 @@ async function currentAuthorization(userId: string) {
478
491
  }
479
492
  ```
480
493
 
481
- #### Considerations
494
+ ##### Considerations
482
495
 
483
496
  - **Wildcard matching**: `admin:*` matches `admin:read`, `admin:write:detail`, etc.; one `*` matches everything after the colon.
484
497
  - **Super-admin role**: `rbac.superAdminRole` (default `super_admin`) automatically grants all permissions.
485
498
  - **Transactions**: `deleteRole` / `deletePermission` clear associations before deleting entities in a DB transaction.
486
- - **Session synchronization**: cache updates are best-effort. Protected requests use `iam.auth.verifyToken` to read current user status and authorization and reject read failures. `iam.session.verifyToken` validates a session snapshot only. `iam.authz.getUserAuthorization(userId)` shares the role JOIN and batched permission query.
499
+ - **Session synchronization**: cache updates are best-effort. Protected requests use `iam.auth.verifyToken` to validate the session-bound persistent credential version, then recheck current user status and credential version and read fresh authorization. Failure at either read rejects authentication. `iam.session.get/verifyToken` also checks persistent user enabled status and `credentialVersion`, but its role/permission fields remain a session snapshot and do not replace current authorization. `iam.authz.getUserAuthorization(userId)` shares the role JOIN and batched permission query.
487
500
  - **Seed data**: `seedDefaultData: true` idempotently creates admin/user/guest roles and basic permissions.
488
501
 
489
502
  ---
490
503
 
491
- ### Users (user) — `iam.user`
504
+ #### Users (user) — `iam.user`
492
505
 
493
- #### Key types
506
+ ##### Key types
494
507
 
495
508
  - **`UserOperations`**: registration, queries, updates, deletion, password changes/resets, and password strength validation.
496
509
  - **`User`** (public fields): `{ id, username, email, phone, displayName, avatarUrl, enabled, roles? }`.
@@ -498,7 +511,7 @@ async function currentAuthorization(userId: string) {
498
511
  - **`RegisterOptions`** / **`RegisterResult`**: registration input/output.
499
512
  - **`UpdateCurrentUserInput`**: fields the current user may change (`username` / `email` / `displayName` / `avatarUrl` / `phone` / `metadata`).
500
513
 
501
- #### Key workflows
514
+ ##### Key workflows
502
515
 
503
516
  **User registration:**
504
517
 
@@ -528,7 +541,7 @@ confirmPasswordReset(token, newPassword)
528
541
  → Delete consumed reset token
529
542
  ```
530
543
 
531
- #### Usage
544
+ ##### Usage
532
545
 
533
546
  `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.
534
547
 
@@ -586,7 +599,7 @@ async function disableUser(userId: string) {
586
599
  }
587
600
  ```
588
601
 
589
- #### Considerations
602
+ ##### Considerations
590
603
 
591
604
  - **`toUser()` redaction**: public users omit sensitive fields such as `passwordHash` and `loginFailedCount`.
592
605
  - **Session invalidation**: `deleteUser` / `updateUser({ enabled: false })` / `changePassword` / `confirmPasswordReset` clear all active sessions for affected users.
@@ -597,17 +610,17 @@ async function disableUser(userId: string) {
597
610
 
598
611
  ---
599
612
 
600
- ### API keys — `iam.apiKey`
613
+ #### API keys — `iam.apiKey`
601
614
 
602
615
  `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 DB failures. Standalone verifyApiKey returns verified key metadata and still requires subject/operation authorization.
603
616
 
604
- #### Key types
617
+ ##### Key types
605
618
 
606
619
  - **`ApiKeyOperations`**: `createApiKey` / `listApiKeys` / `getApiKey` / `revokeApiKey` / `verifyApiKey`.
607
620
  - **`ApiKey`** (public fields): `{ id, userId, name, keyPrefix, enabled, expiresAt, scopes }`.
608
621
  - **`CreateApiKeyResult`**: `{ apiKey, rawKey }`; the plaintext key is returned only once.
609
622
 
610
- #### Key workflows
623
+ ##### Key workflows
611
624
 
612
625
  **API key verification:**
613
626
 
@@ -621,7 +634,7 @@ verifyApiKey(rawKey)
621
634
  → Return ApiKey
622
635
  ```
623
636
 
624
- #### Usage
637
+ ##### Usage
625
638
 
626
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.
627
640
 
@@ -707,7 +720,7 @@ Key-derived sessions remain bound to the Key when refreshed through `iam.session
707
720
 
708
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.
709
722
 
710
- #### Considerations
723
+ ##### Considerations
711
724
 
712
725
  - **No plaintext persistence**: only hashes are stored; `rawKey` at creation is the sole opportunity to retrieve it.
713
726
  - **Prefix lookup**: `keyPrefix` narrows candidates without a full table scan.
@@ -716,11 +729,11 @@ Key-derived sessions remain bound to the Key when refreshed through `iam.session
716
729
 
717
730
  ---
718
731
 
719
- ## More usage
732
+ ### More usage
720
733
 
721
734
  See the skill template (Chinese) for detailed API parameters, codes, and integration patterns: `packages/cli/templates/skills/hai-iam/SKILL.md`.
722
735
 
723
- ## Frontend API calls
736
+ ### Frontend API calls
724
737
 
725
738
  Public HTTP APIs are defined by `@h-ai/api-contract` and called through the `@h-ai/api-client` typed client:
726
739
 
@@ -745,7 +758,9 @@ await apiClient.iam.auth.changePassword({
745
758
  })
746
759
  ```
747
760
 
748
- ## Password reset
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
749
764
 
750
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.
751
766
 
@@ -768,7 +783,9 @@ async function resetByAdministrator(userId: string, newPassword: string) {
768
783
  }
769
784
  ```
770
785
 
771
- ## One-time tickets — `iam.ticket`
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`
772
789
 
773
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.
774
791
 
@@ -801,7 +818,9 @@ async function consumeAudioTicket(ticket: string) {
801
818
 
802
819
  Features: cryptographically secure randomness, TTL, atomic single use (only one concurrent consumer succeeds), and purpose binding to prevent cross-purpose replay.
803
820
 
804
- ## Configuration driven authentication
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.
805
824
 
806
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/defaultEnabled`, `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.
807
826
 
@@ -811,13 +830,13 @@ SvelteKit routes export handlers from `kit.auth.createApi(iam)`; use `kit.auth.p
811
830
 
812
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).
813
832
 
814
- All nodes must share a database and Redis. Memory is limited to one process. Login counters and lock expiry use atomic database updates. OTP attempt limits, resend intervals and consumption use shared state; consuming/exhausting a code does not reset the resend interval. Reset TTL and attempt limits use shared cache and per-user locks: policy failures count, only the newest token is valid, and concurrent use succeeds once. Reset revokes old sessions and clears login locks. Disabled users cannot authenticate. Server password policy is authoritative.
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 the current database generation, 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.
815
834
 
816
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`.
817
836
 
818
837
  `passwordReset.mode` selects `email` (default, `password_reset`) or `phone` (`password_reset_sms`); the public config tells the recovery form which identifier to request.
819
838
 
820
- ### Markdown agreements
839
+ #### Markdown agreements
821
840
 
822
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.
823
842
 
@@ -840,7 +859,7 @@ agreements:
840
859
  # privacyPolicyUrl: https://example.com/privacy
841
860
  ```
842
861
 
843
- ## Error handling
862
+ ### Error handling
844
863
 
845
864
  All operations return `HaiResult<T>`; branch on `HaiIamError`:
846
865
 
@@ -855,14 +874,26 @@ if (!result.success) {
855
874
  }
856
875
  ```
857
876
 
858
- ## Tests
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
+ ## Development and testing
886
+
887
+ 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-与示例验证).
859
888
 
860
889
  ```bash
861
- pnpm test
890
+ pnpm --filter @h-ai/iam typecheck
891
+ pnpm --filter @h-ai/iam lint
892
+ pnpm --filter @h-ai/iam build
893
+ pnpm --filter @h-ai/iam test
894
+ node scripts/check-module-jsdoc.mjs --package iam
862
895
  ```
863
896
 
864
897
  ## License
865
898
 
866
899
  Apache-2.0
867
-
868
- 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.