@h-ai/iam 0.1.0-alpha.61 → 0.1.0-alpha.63

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
@@ -12,7 +12,7 @@ Identity and access management through a unified `iam` object for authentication
12
12
  | -------------------- | ---------------------------------------------------------------------------------------------------------------------- |
13
13
  | **Authentication** | Password, OTP (email/SMS codes), LDAP, and API Key strategies |
14
14
  | **Sessions** | Stateful sessions (random access tokens + cache, optional sliding renewal) |
15
- | **Authorization** | RBAC roles and permissions (DB + cache, wildcard matching) |
15
+ | **Authorization** | RBAC roles and permissions (batched DB reads, wildcard matching) |
16
16
  | **User management** | Registration, queries, updates, password reset, and admin password reset |
17
17
  | **API Key** | Create, revoke, and verify API keys with scopes and expiration |
18
18
  | **One-time tickets** | Short-lived capability tickets (issue / atomic consume) for channels such as WebSocket that cannot carry Bearer tokens |
@@ -27,7 +27,7 @@ pnpm add @h-ai/iam
27
27
  ## Dependencies
28
28
 
29
29
  - `@h-ai/reldb`: persistence for users/roles/permissions; **initialize before iam.init()**.
30
- - `@h-ai/cache`: sessions/OTP/reset tokens/permission cache; **initialize before iam.init()**.
30
+ - `@h-ai/cache`: sessions/OTP/reset tokens/one-time tickets/shared locks; **initialize before iam.init()**.
31
31
  - `@h-ai/crypto`: password hashing, used internally and initialized automatically.
32
32
  - `@h-ai/audit`: audit records for key RBAC operations.
33
33
 
@@ -218,7 +218,7 @@ IAM is a **lifecycle singleton** managed through `iam.init()` / `iam.close()`. F
218
218
  ```
219
219
  iam.init()
220
220
  ├─ session (cached sessions; no DB dependency)
221
- ├─ authz (RBAC roles/permissions; DB + cache)
221
+ ├─ authz (RBAC roles/permissions; batched DB reads)
222
222
  ├─ authn (authentication; depends on session + authz)
223
223
  ├─ user (users; depends on session + authz + passwordStrategy)
224
224
  └─ seed (seed data; depends on authz)
@@ -275,32 +275,41 @@ login(credentials)
275
275
  verifyToken(accessToken)
276
276
  → session.get(accessToken) → cache lookup
277
277
  → Extend TTL automatically when sliding renewal is enabled
278
- → Return Session (userId, roles, permissions)
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
280
+ → Return current Session (userId, roles, permissions)
279
281
  ```
280
282
 
281
283
  #### Usage
282
284
 
283
- `authz.createRole({ ..., permissionIds })` and `authz.updateRole(id, { ..., permissionIds })` save role data and the complete permission set in one transaction; any failure rolls back. `permissionIds: []` clears permissions, omission preserves them, duplicate IDs are deduplicated, and unknown permissions fail. Concurrent updates use the last committed complete set without merging. Self-managed commits synchronize user sessions; with an external transaction, the caller must synchronize after commit. Admin Console uses this API directly, without split updates or compensating role deletion.
285
+ 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.
284
286
 
285
- ```ts
286
- // 密码登录
287
- const result = await iam.auth.login({ identifier: 'admin', password: 'Password123' })
287
+ 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`.
288
288
 
289
- // OTP 登录
290
- await iam.auth.sendOtp('user@example.com')
291
- const result = await iam.auth.loginWithOtp({ identifier: 'user@example.com', code: '123456' })
289
+ ```ts
290
+ import { iam } from '@h-ai/iam'
292
291
 
293
- // LDAP 登录(内置客户端,可选注入 ldapClientFactory)
294
- const result = await iam.auth.loginWithLdap({ username: 'jdoe', password: 'pass' })
292
+ async function passwordLogin(identifier: string, password: string) {
293
+ const result = await iam.auth.login({ identifier, password })
294
+ if (!result.success)
295
+ return result
296
+ // 成功后返回 user、tokens、roles、permissions;令牌只交给安全存储。
297
+ return result
298
+ }
295
299
 
296
- // API Key 登录(需启用 login.apikey: true)
297
- const result = await iam.auth.loginWithApiKey({ key: 'hai_xxxx...' })
300
+ async function requestLoginCode(identifier: string) {
301
+ // identifier 为启用通道对应的邮箱/手机号,返回 expiresAt。
302
+ return iam.auth.sendOtp(identifier)
303
+ }
298
304
 
299
- // 验证令牌
300
- const session = await iam.auth.verifyToken(accessToken)
305
+ async function otpLogin(identifier: string, code: string) {
306
+ // 用户收到验证码并输入后调用;发送失败时不能伪报成功。
307
+ return iam.auth.loginWithOtp({ identifier, code })
308
+ }
301
309
 
302
- // 注册并登录(一站式)
303
- const result = await iam.auth.registerAndLogin({ username: 'new', password: 'Pass123' })
310
+ async function registerWithSession(username: string, password: string) {
311
+ return iam.auth.registerAndLogin({ username, password })
312
+ }
304
313
  ```
305
314
 
306
315
  #### Considerations
@@ -345,23 +354,37 @@ create(options) [singleDevice=true]
345
354
 
346
355
  #### Usage
347
356
 
357
+ 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
+
359
+ 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.
360
+
348
361
  ```ts
349
- // 创建会话(通常由 login 内部调用)
350
- const session = await iam.session.create({
351
- userId: 'user-id',
352
- username: 'admin',
353
- roles: ['admin'],
354
- permissions: ['user:read', 'user:write'],
355
- })
362
+ import { iam } from '@h-ai/iam'
356
363
 
357
- // 验证令牌
358
- const session = await iam.session.verifyToken(accessToken)
364
+ async function authenticateRequest(accessToken: string) {
365
+ // 受保护请求读取当前用户状态及角色/权限,失败时停止业务操作。
366
+ const verified = await iam.auth.verifyToken(accessToken)
367
+ if (!verified.success)
368
+ return verified
369
+ return verified
370
+ }
371
+
372
+ async function rotateTokens(refreshToken: string) {
373
+ const refreshed = await iam.session.refresh(refreshToken)
374
+ if (!refreshed.success)
375
+ return refreshed
376
+ // 安全存储替换两个令牌;下一次请求仍走 auth.verifyToken。
377
+ return refreshed
378
+ }
359
379
 
360
- // 刷新令牌
361
- const newTokens = await iam.session.refresh(refreshToken)
380
+ async function signOut(accessToken: string) {
381
+ return iam.auth.logout(accessToken)
382
+ }
362
383
 
363
- // 强制下线
364
- await iam.session.deleteByUserId(userId)
384
+ async function forceSignOut(userId: string) {
385
+ // 调用前已验证管理员权限,返回撤销的会话数。
386
+ return iam.session.deleteByUserId(userId)
387
+ }
365
388
  ```
366
389
 
367
390
  #### Considerations
@@ -393,7 +416,7 @@ checkPermission(userId, permission)
393
416
  → Return true if RBAC is disabled
394
417
  → Query user roles (DB)
395
418
  → Check super-admin role (cached superAdminRoleId); return true if present
396
- → Get each role's permission codes (cache first; DB on miss, then cache)
419
+ → Read these roles' permission codes in one DB query; return errors on read failure
397
420
  → Match each permission exactly or by wildcard (`admin:*` matches `admin:read`)
398
421
  → Return true / false
399
422
  ```
@@ -403,34 +426,56 @@ checkPermission(userId, permission)
403
426
  ```
404
427
  assignPermissionToRole(roleId, permId)
405
428
  → Write association to DB
406
- → Clear role permission cache
407
429
  → Find role users → resolve permissions per user → update active sessions
408
430
  (best-effort: failures only log.error; permission assignment still succeeds)
409
431
  ```
410
432
 
411
433
  #### Usage
412
434
 
435
+ 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
+
437
+ `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.
438
+
439
+ `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.
440
+
413
441
  ```ts
414
- // 角色管理
415
- const role = await iam.authz.createRole({ code: 'editor', name: '编辑' })
416
- const roles = await iam.authz.getAllRoles({ page: 1, pageSize: 20 })
417
-
418
- // 权限管理
419
- const perm = await iam.authz.createPermission({
420
- code: 'article:publish',
421
- name: '发布文章',
422
- type: 'api',
423
- resource: 'article',
424
- action: 'publish',
425
- })
442
+ import { iam } from '@h-ai/iam'
443
+
444
+ async function provisionReader(userId: string) {
445
+ // 示例初始化权限/角色;生产种子应先按 code 查询,保证幂等。
446
+ const permission = await iam.authz.createPermission({
447
+ code: 'record:read',
448
+ name: '读取记录',
449
+ type: 'api',
450
+ resource: 'record',
451
+ action: 'read',
452
+ })
453
+ if (!permission.success)
454
+ return permission
455
+ const role = await iam.authz.createRole({
456
+ code: 'record_reader',
457
+ name: '记录读取者',
458
+ permissionIds: [permission.data.id],
459
+ })
460
+ if (!role.success)
461
+ return role
462
+ return iam.authz.assignRole(userId, role.data.id)
463
+ }
464
+
465
+ async function replaceRolePermissions(roleId: string, permissionIds: string[]) {
466
+ // 完整替换:[] 清空;省略 permissionIds 则保持已有权限。
467
+ return iam.authz.updateRole(roleId, { permissionIds })
468
+ }
426
469
 
427
- // 分配与检查
428
- await iam.authz.assignRole(userId, role.data.id)
429
- await iam.authz.assignPermissionToRole(role.data.id, perm.data.id)
430
- const allowed = await iam.authz.checkPermission(userId, 'article:publish')
470
+ async function replaceUserRoles(userId: string, roleIds: string[]) {
471
+ // 完整替换用户的角色集合;[] 移除全部角色。
472
+ return iam.authz.syncRoles(userId, roleIds)
473
+ }
431
474
 
432
- // 批量同步角色
433
- await iam.authz.syncRoles(userId, [roleId1, roleId2])
475
+ async function currentAuthorization(userId: string) {
476
+ // 返回 roles/permissions 的 code 数组,不接受 ID 或显示名称代替 code。
477
+ return iam.authz.getUserAuthorization(userId)
478
+ }
434
479
  ```
435
480
 
436
481
  #### Considerations
@@ -438,7 +483,7 @@ await iam.authz.syncRoles(userId, [roleId1, roleId2])
438
483
  - **Wildcard matching**: `admin:*` matches `admin:read`, `admin:write:detail`, etc.; one `*` matches everything after the colon.
439
484
  - **Super-admin role**: `rbac.superAdminRole` (default `super_admin`) automatically grants all permissions.
440
485
  - **Transactions**: `deleteRole` / `deletePermission` clear associations before deleting entities in a DB transaction.
441
- - **Session synchronization latency**: permission changes update active session permissions on a best-effort basis; many users may cause delay.
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.
442
487
  - **Seed data**: `seedDefaultData: true` idempotently creates admin/user/guest roles and basic permissions.
443
488
 
444
489
  ---
@@ -451,7 +496,7 @@ await iam.authz.syncRoles(userId, [roleId1, roleId2])
451
496
  - **`User`** (public fields): `{ id, username, email, phone, displayName, avatarUrl, enabled, roles? }`.
452
497
  - **`StoredUser`**: internal stored user, including sensitive `passwordHash`, `loginFailedCount`, `lockedUntil`, etc.
453
498
  - **`RegisterOptions`** / **`RegisterResult`**: registration input/output.
454
- - **`UpdateCurrentUserInput`**: fields the current user may change (`displayName` / `avatarUrl` / `phone` / `metadata`).
499
+ - **`UpdateCurrentUserInput`**: fields the current user may change (`username` / `email` / `displayName` / `avatarUrl` / `phone` / `metadata`).
455
500
 
456
501
  #### Key workflows
457
502
 
@@ -485,38 +530,60 @@ confirmPasswordReset(token, newPassword)
485
530
 
486
531
  #### Usage
487
532
 
533
+ `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
+
535
+ 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.
536
+
537
+ `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.
538
+
488
539
  ```ts
489
- // 注册
490
- const result = await iam.user.register({
491
- username: 'alice',
492
- email: 'alice@example.com',
493
- password: 'Password123',
494
- })
540
+ import { iam } from '@h-ai/iam'
495
541
 
496
- // 当前用户操作
497
- const me = await iam.user.getCurrentUser(accessToken)
498
- await iam.user.updateCurrentUser(accessToken, { displayName: '新名称' })
499
- await iam.user.changeCurrentUserPassword(accessToken, 'oldPass', 'newPass')
500
-
501
- // 管理操作
502
- const users = await iam.user.listUsers({
503
- page: 1,
504
- pageSize: 20,
505
- search: 'alice',
506
- sortBy: 'createdAt',
507
- sortDirection: 'desc',
508
- include: ['roles'],
509
- })
510
- await iam.user.updateUser(userId, { enabled: false }) // 禁用用户 → 自动清除会话
511
- await iam.user.deleteUser(userId) // 删除用户 → 事务清除角色关联
542
+ async function registerUser(username: string, email: string, password: string) {
543
+ // 返回 RegisterResult,不自动建立会话。
544
+ return iam.user.register({ username, email, password })
545
+ }
546
+
547
+ async function createManagedUser(username: string, password: string) {
548
+ // 先验证调用者管理权限;不受 register.enabled 开关限制。
549
+ return iam.user.createUser({ username, password })
550
+ }
551
+
552
+ async function updateMyProfile(accessToken: string, displayName: string) {
553
+ return iam.user.updateCurrentUser(accessToken, { displayName })
554
+ }
512
555
 
513
- // 密码重置
514
- await iam.user.requestPasswordReset('alice@example.com')
515
- await iam.user.confirmPasswordReset(token, 'NewPassword456')
516
- await iam.user.adminResetPassword(userId, 'TempPassword123')
556
+ async function changeMyPassword(accessToken: string, oldPassword: string, newPassword: string) {
557
+ // 成功后撤销该用户全部会话,客户端回到登录入口。
558
+ return iam.user.changeCurrentUserPassword(accessToken, oldPassword, newPassword)
559
+ }
560
+
561
+ async function listReaders(page: number) {
562
+ const result = await iam.user.listUsers({
563
+ page,
564
+ pageSize: 20,
565
+ roleCode: 'record_reader',
566
+ search: 'alice',
567
+ enabled: true,
568
+ include: ['roles'],
569
+ sortBy: 'createdAt',
570
+ sortDirection: 'desc',
571
+ })
572
+ if (!result.success)
573
+ return result
574
+ // 直接使用 items/total/page/pageSize,不对当前页再次过滤。
575
+ return result
576
+ }
577
+
578
+ async function loadUsers(userIds: readonly string[]) {
579
+ // 最多 1000 个输入 ID;去重保留输入顺序,缺失 ID 省略。
580
+ return iam.user.getUsers(userIds, { include: ['roles'] })
581
+ }
517
582
 
518
- // 密码强度验证(同步方法)
519
- const valid = iam.user.validatePassword('weak')
583
+ async function disableUser(userId: string) {
584
+ // 调用前已验证管理权限;成功后自动清除该用户会话。
585
+ return iam.user.updateUser(userId, { enabled: false })
586
+ }
520
587
  ```
521
588
 
522
589
  #### Considerations
@@ -532,6 +599,8 @@ const valid = iam.user.validatePassword('weak')
532
599
 
533
600
  ### API keys — `iam.apiKey`
534
601
 
602
+ `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
+
535
604
  #### Key types
536
605
 
537
606
  - **`ApiKeyOperations`**: `createApiKey` / `listApiKeys` / `getApiKey` / `revokeApiKey` / `verifyApiKey`.
@@ -554,23 +623,90 @@ verifyApiKey(rawKey)
554
623
 
555
624
  #### Usage
556
625
 
626
+ 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
+
628
+ ```yaml
629
+ # config/_iam.yml
630
+ login:
631
+ apikey: true
632
+ apikey:
633
+ maxKeysPerUser: 10
634
+ defaultExpirationDays: 30
635
+ ```
636
+
637
+ For a subject holding `record:read`, `record:write` and `user:read`, the results are recalculated whenever subject authorization changes:
638
+
639
+ | scopes | Effective permissions | roles |
640
+ | ------------------------------ | --------------------------------------- | ---------------------- |
641
+ | Omitted or `[]` | Identity only, no operation permissions | `[]` |
642
+ | `['record:read']` | `record:read` | `[]` |
643
+ | `['record:*']` | `record:read`, `record:write` | `[]` |
644
+ | `['record:read', 'user:read']` | Both specified permissions | `[]` |
645
+ | `['missing:read']` | None, because the subject lacks it | `[]` |
646
+ | `['*']` | All current subject permissions | Subject roles retained |
647
+
648
+ `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.
649
+
650
+ 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.
651
+
557
652
  ```ts
558
- // 创建 API Key
559
- const result = await iam.apiKey.createApiKey(userId, {
560
- name: 'CI/CD',
561
- expirationDays: 90,
562
- scopes: ['api:read'],
563
- })
564
- // result.data.rawKey → 明文密钥(仅此一次展示)
653
+ import { err, HaiCommonError, ok } from '@h-ai/core'
654
+ import { iam } from '@h-ai/iam'
655
+
656
+ async function createReadKey(userId: string) {
657
+ // 调用前校验管理权限或本人归属;scopes 使用权限 code。
658
+ const created = await iam.apiKey.createApiKey(userId, {
659
+ name: 'Read client',
660
+ expirationDays: 30,
661
+ scopes: ['record:read'],
662
+ })
663
+ if (!created.success)
664
+ return created
665
+ // rawKey 仅本次返回;通过安全渠道展示一次,不写日志。
666
+ return created
667
+ }
668
+
669
+ async function apiKeyLogin(rawKey: string) {
670
+ // 通常由客户端取得 Key 后调用;服务器内部验证并绑定 Key。
671
+ return iam.auth.loginWithApiKey({ key: rawKey })
672
+ }
673
+
674
+ async function requireRecordRead(accessToken: string, deniedMessage: string) {
675
+ // deniedMessage 由应用 i18n 提供;先读取当前且已收敛 scope 的会话。
676
+ const verified = await iam.auth.verifyToken(accessToken)
677
+ if (!verified.success)
678
+ return verified
679
+ const allowed = verified.data.permissions.some(code =>
680
+ code === '*' || code === 'record:read'
681
+ || (code.endsWith(':*') && 'record:read'.startsWith(code.slice(0, -1))),
682
+ )
683
+ if (!allowed)
684
+ return err(HaiCommonError.FORBIDDEN, deniedMessage)
685
+ // 这里只确认操作权限;业务层还须检查具体记录的数据归属。
686
+ return ok({ userId: verified.data.userId })
687
+ }
565
688
 
566
- // 列出用户的 API Key(服务端分页)
567
- const keys = await iam.apiKey.listApiKeys(userId, { page: 1, pageSize: 20, search: 'ci' })
568
- // keys.data → { items, total, page, pageSize }
689
+ async function listMyKeys(userId: string) {
690
+ return iam.apiKey.listApiKeys(userId, {
691
+ page: 1,
692
+ pageSize: 20,
693
+ search: 'Read',
694
+ enabled: true,
695
+ })
696
+ }
569
697
 
570
- // 吊销
571
- await iam.apiKey.revokeApiKey(keyId)
698
+ async function revokeKey(keyId: string) {
699
+ // 调用前校验本人归属或管理权限。
700
+ return iam.apiKey.revokeApiKey(keyId)
701
+ }
572
702
  ```
573
703
 
704
+ 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.
705
+
706
+ 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.
707
+
708
+ `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
+
574
710
  #### Considerations
575
711
 
576
712
  - **No plaintext persistence**: only hashes are stored; `rawKey` at creation is the sole opportunity to retrieve it.
@@ -611,15 +747,25 @@ await apiClient.iam.auth.changePassword({
611
747
 
612
748
  ## Password reset
613
749
 
750
+ 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
+
614
752
  ```ts
615
- // 请求重置(即使用户不存在也返回 ok,防止枚举)
616
- await iam.user.requestPasswordReset('admin@example.com')
753
+ import { iam } from '@h-ai/iam'
617
754
 
618
- // 确认重置(校验令牌有效期和尝试次数 → 更新密码 → 清除所有会话)
619
- const result = await iam.user.confirmPasswordReset(token, 'NewPassword456')
755
+ async function requestReset(identifier: string) {
756
+ // 缺失用户与有效用户使用相同成功响应,避免枚举账户。
757
+ return iam.user.requestPasswordReset(identifier)
758
+ }
759
+
760
+ async function confirmReset(token: string, newPassword: string) {
761
+ // token 来自通知中的重置链接;成功后重新登录。
762
+ return iam.user.confirmPasswordReset(token, newPassword)
763
+ }
620
764
 
621
- // 管理员重置(无需旧密码)
622
- await iam.user.adminResetPassword(userId, 'TempPassword123')
765
+ async function resetByAdministrator(userId: string, newPassword: string) {
766
+ // 管理权限校验在服务端入口完成;不接受未经校验的 userId。
767
+ return iam.user.adminResetPassword(userId, newPassword)
768
+ }
623
769
  ```
624
770
 
625
771
  ## One-time tickets — `iam.ticket`
@@ -627,20 +773,29 @@ await iam.user.adminResetPassword(userId, 'TempPassword123')
627
773
  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.
628
774
 
629
775
  ```ts
630
- // 签发(已登录 HTTP 请求内):绑定主体、用途与本次操作,30 秒过期
631
- const issued = await iam.ticket.issue({
632
- subjectId: session.userId,
633
- purpose: 'ai-audio',
634
- grant: { operation: 'transcribe', model: 'whisper-1' },
635
- ttlMs: 30_000,
636
- })
637
- // issued.data.ticket 交给客户端,用于建立 WebSocket 连接
776
+ import type { Session } from '@h-ai/iam'
777
+ import { iam } from '@h-ai/iam'
778
+
779
+ async function issueAudioTicket(session: Session) {
780
+ // session 已通过当前授权校验,签发前还须验证该操作及资源范围。
781
+ const issued = await iam.ticket.issue({
782
+ subjectId: session.userId,
783
+ purpose: 'ai-audio',
784
+ grant: { operation: 'transcribe', model: 'whisper-1' },
785
+ ttlMs: 30_000,
786
+ })
787
+ if (!issued.success)
788
+ return issued
789
+ // 仅将短期票据交给客户端,不把长期 accessToken 放进 URL。
790
+ return issued
791
+ }
638
792
 
639
- // 消费(服务端建连时):原子单次,返回主体 + 用途 + grant
640
- const consumed = await iam.ticket.consume(ticket, { purpose: 'ai-audio' })
641
- if (consumed.success) {
642
- const { subjectId, grant } = consumed.data
643
- // 用 grant.operation / grant.model 交叉校验客户端请求
793
+ async function consumeAudioTicket(ticket: string) {
794
+ const consumed = await iam.ticket.consume(ticket, { purpose: 'ai-audio' })
795
+ if (!consumed.success)
796
+ return consumed
797
+ // 按 grant 校验本次建连操作/资源;票据本身不自动读取当前 RBAC。
798
+ return consumed
644
799
  }
645
800
  ```
646
801