@jini-ai/user-management 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/LICENSE +201 -0
  3. package/README.md +104 -0
  4. package/dist/admin/adapters/http.d.ts +29 -0
  5. package/dist/admin/adapters/http.js +69 -0
  6. package/dist/admin/adapters/memory.d.ts +61 -0
  7. package/dist/admin/adapters/memory.js +217 -0
  8. package/dist/admin/auth.module.d.ts +30 -0
  9. package/dist/admin/auth.module.js +12 -0
  10. package/dist/admin/conformance/index.d.ts +56 -0
  11. package/dist/admin/conformance/index.js +156 -0
  12. package/dist/admin/controllers/auth.controller.d.ts +23 -0
  13. package/dist/admin/controllers/auth.controller.js +33 -0
  14. package/dist/admin/controllers/controller-store.d.ts +18 -0
  15. package/dist/admin/controllers/controller-store.js +23 -0
  16. package/dist/admin/controllers/members.controller.d.ts +33 -0
  17. package/dist/admin/controllers/members.controller.js +119 -0
  18. package/dist/admin/controllers/roles.controller.d.ts +47 -0
  19. package/dist/admin/controllers/roles.controller.js +282 -0
  20. package/dist/admin/controllers/users.controller.d.ts +53 -0
  21. package/dist/admin/controllers/users.controller.js +220 -0
  22. package/dist/admin/index.d.ts +21 -0
  23. package/dist/admin/index.js +15 -0
  24. package/dist/admin/members.module.d.ts +34 -0
  25. package/dist/admin/members.module.js +13 -0
  26. package/dist/admin/messages.en.d.ts +37 -0
  27. package/dist/admin/messages.en.js +21 -0
  28. package/dist/admin/models.d.ts +133 -0
  29. package/dist/admin/models.js +2 -0
  30. package/dist/admin/people.rules.d.ts +29 -0
  31. package/dist/admin/people.rules.js +51 -0
  32. package/dist/admin/ports.d.ts +187 -0
  33. package/dist/admin/ports.js +17 -0
  34. package/dist/admin/react/components/DestructiveDialog.d.ts +5 -0
  35. package/dist/admin/react/components/DestructiveDialog.js +6 -0
  36. package/dist/admin/react/components/MemberRow.d.ts +5 -0
  37. package/dist/admin/react/components/MemberRow.js +8 -0
  38. package/dist/admin/react/components/MembersConfirmation.d.ts +2 -0
  39. package/dist/admin/react/components/MembersConfirmation.js +8 -0
  40. package/dist/admin/react/components/PermissionPanel.d.ts +5 -0
  41. package/dist/admin/react/components/PermissionPanel.js +9 -0
  42. package/dist/admin/react/components/PolicyRow.d.ts +5 -0
  43. package/dist/admin/react/components/PolicyRow.js +11 -0
  44. package/dist/admin/react/components/RoleRow.d.ts +5 -0
  45. package/dist/admin/react/components/RoleRow.js +10 -0
  46. package/dist/admin/react/components/UserRow.d.ts +5 -0
  47. package/dist/admin/react/components/UserRow.js +8 -0
  48. package/dist/admin/react/components/UsersConfirmation.d.ts +3 -0
  49. package/dist/admin/react/components/UsersConfirmation.js +9 -0
  50. package/dist/admin/react/hooks/AuthBinding.hooks.d.ts +39 -0
  51. package/dist/admin/react/hooks/AuthBinding.hooks.js +21 -0
  52. package/dist/admin/react/hooks/LoginPage.hooks.d.ts +54 -0
  53. package/dist/admin/react/hooks/LoginPage.hooks.js +23 -0
  54. package/dist/admin/react/hooks/MembersBinding.hooks.d.ts +35 -0
  55. package/dist/admin/react/hooks/MembersBinding.hooks.js +21 -0
  56. package/dist/admin/react/hooks/MembersPage.hooks.d.ts +99 -0
  57. package/dist/admin/react/hooks/MembersPage.hooks.js +38 -0
  58. package/dist/admin/react/hooks/PeopleController.hooks.d.ts +18 -0
  59. package/dist/admin/react/hooks/PeopleController.hooks.js +20 -0
  60. package/dist/admin/react/hooks/PermissionPanel.hooks.d.ts +30 -0
  61. package/dist/admin/react/hooks/PermissionPanel.hooks.js +15 -0
  62. package/dist/admin/react/hooks/PoliciesTab.hooks.d.ts +47 -0
  63. package/dist/admin/react/hooks/PoliciesTab.hooks.js +26 -0
  64. package/dist/admin/react/hooks/RolesBinding.hooks.d.ts +39 -0
  65. package/dist/admin/react/hooks/RolesBinding.hooks.js +23 -0
  66. package/dist/admin/react/hooks/RolesController.hooks.d.ts +43 -0
  67. package/dist/admin/react/hooks/RolesController.hooks.js +25 -0
  68. package/dist/admin/react/hooks/RolesPage.hooks.d.ts +105 -0
  69. package/dist/admin/react/hooks/RolesPage.hooks.js +33 -0
  70. package/dist/admin/react/hooks/RolesTab.hooks.d.ts +35 -0
  71. package/dist/admin/react/hooks/RolesTab.hooks.js +19 -0
  72. package/dist/admin/react/hooks/UsersBinding.hooks.d.ts +36 -0
  73. package/dist/admin/react/hooks/UsersBinding.hooks.js +21 -0
  74. package/dist/admin/react/hooks/UsersPage.hooks.d.ts +106 -0
  75. package/dist/admin/react/hooks/UsersPage.hooks.js +22 -0
  76. package/dist/admin/react/hooks/UsersTab.hooks.d.ts +101 -0
  77. package/dist/admin/react/hooks/UsersTab.hooks.js +53 -0
  78. package/dist/admin/react/index.d.ts +203 -0
  79. package/dist/admin/react/index.js +32 -0
  80. package/dist/admin/react/pages/LoginPage.d.ts +3 -0
  81. package/dist/admin/react/pages/LoginPage.js +13 -0
  82. package/dist/admin/react/pages/MembersPage.d.ts +3 -0
  83. package/dist/admin/react/pages/MembersPage.js +19 -0
  84. package/dist/admin/react/pages/RolesPage.d.ts +3 -0
  85. package/dist/admin/react/pages/RolesPage.js +19 -0
  86. package/dist/admin/react/pages/UsersPage.d.ts +3 -0
  87. package/dist/admin/react/pages/UsersPage.js +19 -0
  88. package/dist/admin/react/tabs/AuthTab.d.ts +4 -0
  89. package/dist/admin/react/tabs/AuthTab.js +8 -0
  90. package/dist/admin/react/tabs/MembersTab.d.ts +4 -0
  91. package/dist/admin/react/tabs/MembersTab.js +9 -0
  92. package/dist/admin/react/tabs/PoliciesTab.d.ts +4 -0
  93. package/dist/admin/react/tabs/PoliciesTab.js +9 -0
  94. package/dist/admin/react/tabs/RolesTab.d.ts +4 -0
  95. package/dist/admin/react/tabs/RolesTab.js +9 -0
  96. package/dist/admin/react/tabs/UsersTab.d.ts +4 -0
  97. package/dist/admin/react/tabs/UsersTab.js +9 -0
  98. package/dist/admin/roles.module.d.ts +48 -0
  99. package/dist/admin/roles.module.js +15 -0
  100. package/dist/admin/rules.d.ts +32 -0
  101. package/dist/admin/rules.js +32 -0
  102. package/dist/admin/users.module.d.ts +39 -0
  103. package/dist/admin/users.module.js +14 -0
  104. package/dist/core/builtin-permissions.d.ts +10 -0
  105. package/dist/core/builtin-permissions.js +402 -0
  106. package/dist/core/index.d.ts +6 -0
  107. package/dist/core/index.js +9 -0
  108. package/dist/core/permissions.d.ts +40 -0
  109. package/dist/core/permissions.js +52 -0
  110. package/dist/core/ports.d.ts +237 -0
  111. package/dist/core/ports.js +2 -0
  112. package/dist/core/runtime-ports.d.ts +8 -0
  113. package/dist/core/runtime-ports.js +2 -0
  114. package/dist/core/types.d.ts +224 -0
  115. package/dist/core/types.js +93 -0
  116. package/dist/react/auth/Login.d.ts +9 -0
  117. package/dist/react/auth/Login.js +8 -0
  118. package/dist/react/auth/hooks/use-login.hooks.d.ts +22 -0
  119. package/dist/react/auth/hooks/use-login.hooks.js +24 -0
  120. package/dist/react/errors.d.ts +10 -0
  121. package/dist/react/errors.js +13 -0
  122. package/dist/react/index.d.ts +18 -0
  123. package/dist/react/index.js +9 -0
  124. package/dist/react/members/Members.d.ts +6 -0
  125. package/dist/react/members/Members.js +38 -0
  126. package/dist/react/members/hooks/use-members.hooks.d.ts +32 -0
  127. package/dist/react/members/hooks/use-members.hooks.js +120 -0
  128. package/dist/react/members/rules.d.ts +26 -0
  129. package/dist/react/members/rules.js +35 -0
  130. package/dist/react/models.d.ts +50 -0
  131. package/dist/react/models.js +2 -0
  132. package/dist/react/ports.d.ts +157 -0
  133. package/dist/react/ports.js +2 -0
  134. package/dist/react/roles/Roles.d.ts +100 -0
  135. package/dist/react/roles/Roles.hooks.d.ts +17 -0
  136. package/dist/react/roles/Roles.hooks.js +40 -0
  137. package/dist/react/roles/Roles.js +150 -0
  138. package/dist/react/roles/hooks/use-roles.hooks.d.ts +80 -0
  139. package/dist/react/roles/hooks/use-roles.hooks.js +303 -0
  140. package/dist/react/roles/rules.d.ts +35 -0
  141. package/dist/react/roles/rules.js +31 -0
  142. package/dist/react/roles/text.d.ts +27 -0
  143. package/dist/react/roles/text.js +13 -0
  144. package/dist/react/testing.d.ts +51 -0
  145. package/dist/react/testing.js +275 -0
  146. package/dist/react/users/Users.d.ts +72 -0
  147. package/dist/react/users/Users.js +129 -0
  148. package/dist/react/users/hooks/use-reset-password-fields.hooks.d.ts +16 -0
  149. package/dist/react/users/hooks/use-reset-password-fields.hooks.js +21 -0
  150. package/dist/react/users/hooks/use-users.hooks.d.ts +77 -0
  151. package/dist/react/users/hooks/use-users.hooks.js +286 -0
  152. package/dist/react/users/rules.d.ts +33 -0
  153. package/dist/react/users/rules.js +61 -0
  154. package/dist/server/admin-crud-service.d.ts +295 -0
  155. package/dist/server/admin-crud-service.js +561 -0
  156. package/dist/server/agent-tool-input.d.ts +58 -0
  157. package/dist/server/agent-tool-input.js +158 -0
  158. package/dist/server/agent-tool-registrations.d.ts +51 -0
  159. package/dist/server/agent-tool-registrations.js +417 -0
  160. package/dist/server/agent-tools.d.ts +88 -0
  161. package/dist/server/agent-tools.js +229 -0
  162. package/dist/server/auth-service.d.ts +107 -0
  163. package/dist/server/auth-service.js +170 -0
  164. package/dist/server/authorize.d.ts +92 -0
  165. package/dist/server/authorize.js +96 -0
  166. package/dist/server/grant-service.d.ts +219 -0
  167. package/dist/server/grant-service.js +419 -0
  168. package/dist/server/hasher.d.ts +64 -0
  169. package/dist/server/hasher.js +53 -0
  170. package/dist/server/identity-transaction.d.ts +9 -0
  171. package/dist/server/identity-transaction.js +8 -0
  172. package/dist/server/index.d.ts +18 -0
  173. package/dist/server/index.js +20 -0
  174. package/dist/server/messages.d.ts +7 -0
  175. package/dist/server/messages.js +5 -0
  176. package/dist/server/password-policy.d.ts +59 -0
  177. package/dist/server/password-policy.js +66 -0
  178. package/dist/server/permission-migrations.d.ts +84 -0
  179. package/dist/server/permission-migrations.js +77 -0
  180. package/dist/server/repo.memory-transactions.d.ts +16 -0
  181. package/dist/server/repo.memory-transactions.js +79 -0
  182. package/dist/server/repo.memory.d.ts +186 -0
  183. package/dist/server/repo.memory.js +230 -0
  184. package/dist/server/seed.d.ts +56 -0
  185. package/dist/server/seed.js +435 -0
  186. package/dist/server/session-tokens.d.ts +10 -0
  187. package/dist/server/session-tokens.js +12 -0
  188. package/dist/server/username.d.ts +14 -0
  189. package/dist/server/username.js +14 -0
  190. package/package.json +144 -0
@@ -0,0 +1,561 @@
1
+ import { nowIso as kernelNowIso } from "@jini-ai/core/primitives";
2
+ import { inIdentityTransaction } from "./identity-transaction.js";
3
+ import { assertCallerHasAnyPermission, assertGrantClamp, assertOwnerTargetMayBeModified, principalHoldsOwnerWildcard } from "./grant-service.js";
4
+ import { validatePasswordPolicy } from "./password-policy.js";
5
+ import { isKnownPermission } from "../core/permissions.js";
6
+ import { IdentityConflictError, IdentityNotFoundError, IdentityValidationError, OwnerRequiredError, PermissionUnknownError, } from "../core/types.js";
7
+ /**
8
+ * @file An amendment that completes the users/roles/policies admin CRUD surface
9
+ * (feature.spec.md..19).
10
+ *
11
+ * Purpose:
12
+ * Two of these nine transitions (`disablePrincipal`, `writePolicyPermission`) were fully specified
13
+ * since v0.5.0/v0.5.3 but never implemented — this file is their first implementation, not a
14
+ * re-specification. The other seven (`enablePrincipal`, `updateUser`,
15
+ * `resetUserPassword`, `updateRole`, `updatePolicy`, `deleteRole`, `deletePolicy`) are new
16
+ * transitions this amendment adds. Grouped in one file, separate from `grant-service.ts`'s
17
+ * "human grant-writing transitions" (`CREATE_*`/`ASSIGN_ROLE`/`ATTACH_POLICY`) because most of
18
+ * these are plain CRUD, not grant-writing — `writePolicyPermission` is the one exception (it IS
19
+ * -clamped) and reuses `grant-service.ts`'s exported `assertGrantClamp` rather than
20
+ * duplicating it.
21
+ *
22
+ * Architectural role:
23
+ * Ordinary core functions — same shape as `grant-service.ts`: one exported async
24
+ * function per transition, plain `Error` subclasses for control flow, the caller-permission gate
25
+ * enforced here (not pushed to the route layer).
26
+ * See docs/decisions/DR-002-grant-authority-and-admin-safety.md.
27
+ */
28
+ /**
29
+ * `DISABLE_PRINCIPAL` (feature.spec.md, state.spec §3). Gated by
30
+ * `user.manage`. Refuses the seeded owner unconditionally ( CLI/core resolution target —
31
+ * disabling it would strand the CLI-only management plane) and refuses any disable that would drop
32
+ * the workspace's active owner-`*` count to zero — both checked, and the status update
33
+ * performed inside one identity transaction. Asynchronous repository calls yield even in a
34
+ * single-event-loop composition, so the transaction port must serialize competing owner/status
35
+ * writes for the whole workspace rather than rely on "atomic by construction" reasoning.
36
+ *
37
+ * `input.seededOwnerPrincipalId` is the caller's job to resolve (`await deps.ownerPrincipalId` at
38
+ * the route layer, mirroring `identityReady`'s existing await-before-use convention) — this
39
+ * function takes the already-resolved id so it stays a plain, directly-testable function.
40
+ *
41
+ * @complexity O(n) in the workspace's principal count (one `list` call to count active owner-`*`
42
+ * holders) — bounded by the same operator-managed-roster assumption `identity/INFO.md` already
43
+ * makes for `PrincipalRepoPort.list`.
44
+ * @overallScore 100
45
+ * See docs/decisions/DR-002-grant-authority-and-admin-safety.md.
46
+ */
47
+ export async function disablePrincipal(required) {
48
+ const { deps, input } = required;
49
+ return inIdentityTransaction({
50
+ transactions: deps.repos.transactions,
51
+ workspaceId: input.workspaceId,
52
+ execute: async () => {
53
+ await assertCallerHasAnyPermission({
54
+ deps,
55
+ workspaceId: input.workspaceId,
56
+ callerPrincipalId: input.callerPrincipalId,
57
+ permissions: ["user.manage"],
58
+ });
59
+ const target = await deps.repos.principals.findById({ workspaceId: input.workspaceId, id: input.principalId });
60
+ if (!target)
61
+ throw new IdentityNotFoundError({ message: `principal '${input.principalId}' was not found` });
62
+ if (target.id === input.seededOwnerPrincipalId) {
63
+ // See docs/decisions/DR-002-grant-authority-and-admin-safety.md for the invariant behind this refusal; keep external identifiers out of caller-facing messages.
64
+ throw new OwnerRequiredError({ message: "the seeded owner principal can never be disabled" });
65
+ }
66
+ await assertOwnerTargetMayBeModified({
67
+ deps,
68
+ workspaceId: input.workspaceId,
69
+ callerPrincipalId: input.callerPrincipalId,
70
+ principalId: input.principalId,
71
+ });
72
+ if (target.status === "disabled") {
73
+ // Idempotent — matches LOGOUT's "already revoked is a no-op" discipline (state.spec §3).
74
+ return { principal: target };
75
+ }
76
+ const holdsOwnerWildcard = await principalHoldsOwnerWildcard({
77
+ deps,
78
+ workspaceId: input.workspaceId,
79
+ principalId: target.id,
80
+ });
81
+ if (holdsOwnerWildcard) {
82
+ const activeOwnerCount = await countActiveOwnerWildcardPrincipals({ deps, workspaceId: input.workspaceId });
83
+ if (activeOwnerCount <= 1) {
84
+ // See docs/decisions/DR-002-grant-authority-and-admin-safety.md for the invariant behind this refusal; keep external identifiers out of caller-facing messages.
85
+ throw new OwnerRequiredError({ message: "the workspace must keep at least one active owner-`*` principal" });
86
+ }
87
+ }
88
+ const disabled = { ...target, status: "disabled", disabledAt: kernelNowIso({ clock: deps.clock }) };
89
+ await deps.repos.principals.save(disabled);
90
+ return { principal: disabled };
91
+ },
92
+ });
93
+ }
94
+ /**
95
+ * `ENABLE_PRINCIPAL` (feature.spec.md ). Gated by `user.manage`. Target must be
96
+ * `kind='user'` — a `system`/`api_key` target is rejected `VALIDATION_ERROR` (mirrors
97
+ * human-only scoping; re-activating the disabled legacy `user-local` stub or an api_key principal
98
+ * outside `ISSUE_API_KEY`'s own lifecycle is never a valid call). Already-active is idempotent
99
+ * (mirrors `disablePrincipal`'s own already-disabled no-op above).
100
+ *
101
+ * @complexity O(1) — one lookup, one save.
102
+ * @overallScore 100
103
+ * See docs/decisions/DR-002-grant-authority-and-admin-safety.md.
104
+ */
105
+ export async function enablePrincipal(required) {
106
+ const { deps, input } = required;
107
+ return inIdentityTransaction({
108
+ transactions: deps.repos.transactions,
109
+ workspaceId: input.workspaceId,
110
+ execute: async () => {
111
+ await assertCallerHasAnyPermission({
112
+ deps,
113
+ workspaceId: input.workspaceId,
114
+ callerPrincipalId: input.callerPrincipalId,
115
+ permissions: ["user.manage"],
116
+ });
117
+ const target = await deps.repos.principals.findById({ workspaceId: input.workspaceId, id: input.principalId });
118
+ if (!target)
119
+ throw new IdentityNotFoundError({ message: `principal '${input.principalId}' was not found` });
120
+ if (target.kind !== "user") {
121
+ throw new IdentityValidationError({ message: `ENABLE_PRINCIPAL target must be a human (kind='user') principal, got kind='${target.kind}'` });
122
+ }
123
+ await assertOwnerTargetMayBeModified({
124
+ deps,
125
+ workspaceId: input.workspaceId,
126
+ callerPrincipalId: input.callerPrincipalId,
127
+ principalId: input.principalId,
128
+ });
129
+ if (target.status === "active") {
130
+ return { principal: target };
131
+ }
132
+ const enabled = { ...target, status: "active", disabledAt: undefined };
133
+ await deps.repos.principals.save(enabled);
134
+ return { principal: enabled };
135
+ },
136
+ });
137
+ }
138
+ /**
139
+ * `UPDATE_USER` (feature.spec.md ). Gated by `user.manage`; existing self-profile edits also
140
+ * accept `member.manage`. Only owners may edit owner targets — `email` only; `username`/`password` are not
141
+ * this transition's concern ( respectively). An absent or empty-string `email`
142
+ * clears the stored value to `undefined` ( — no distinct "blank but present" state).
143
+ *
144
+ * @complexity O(1) — one lookup, one save.
145
+ * @overallScore 100
146
+ * See docs/decisions/DR-002-grant-authority-and-admin-safety.md.
147
+ */
148
+ export async function updateUser(required, optional = {}) {
149
+ const { deps } = required;
150
+ const input = { ...required.input, ...optional };
151
+ return inIdentityTransaction({
152
+ transactions: deps.repos.transactions,
153
+ workspaceId: input.workspaceId,
154
+ execute: async () => {
155
+ await assertCallerHasAnyPermission({
156
+ deps,
157
+ workspaceId: input.workspaceId,
158
+ callerPrincipalId: input.callerPrincipalId,
159
+ // Preserve existing self-profile access without letting member.manage edit other operators.
160
+ permissions: input.callerPrincipalId === input.principalId
161
+ ? ["user.manage", "member.manage"]
162
+ : ["user.manage"],
163
+ });
164
+ const target = await deps.repos.users.findByPrincipalId({
165
+ workspaceId: input.workspaceId,
166
+ principalId: input.principalId,
167
+ });
168
+ if (!target)
169
+ throw new IdentityNotFoundError({ message: `user '${input.principalId}' was not found` });
170
+ await assertOwnerTargetMayBeModified({
171
+ deps,
172
+ workspaceId: input.workspaceId,
173
+ callerPrincipalId: input.callerPrincipalId,
174
+ principalId: input.principalId,
175
+ });
176
+ const updated = { ...target, email: input.email ? input.email : undefined };
177
+ await deps.repos.users.save(updated);
178
+ return { user: updated };
179
+ },
180
+ });
181
+ }
182
+ /**
183
+ * `RESET_USER_PASSWORD` (feature.spec.md ). Gated by **`user.manage`** only
184
+ * (resetting a credential is at least as sensitive as disabling the account, own reasoning). Hashes the new password identically to `CREATE_USER` (argon2id,
185
+ * ), then revokes every one of the target's active sessions (idempotent no-op if it has none,
186
+ * ) — a reset that left old sessions alive would not actually contain a compromised account.
187
+ *
188
+ * Refuses a THIRD PARTY resetting the seeded owner's password ( reasoning, same
189
+ * `OwnerRequiredError` `disablePrincipal` uses): `user.manage` is independently grantable and not
190
+ * owner-exclusive (`permissions.ts` — "Create/disable operator users and principals"), so without
191
+ * this check a caller holding only that one delegated permission could set a password of their own
192
+ * choosing on the seeded owner's account and log in as owner — a full takeover from a routine
193
+ * delegation. Checked before the password-policy validation so the refusal doesn't depend on the
194
+ * caller supplying a well-formed password first.
195
+ *
196
+ * Deliberately NOT unconditional the way `disablePrincipal`'s owner check is: the owner resetting
197
+ * ITS OWN password (`callerPrincipalId === principalId === seededOwnerPrincipalId`) is refused
198
+ * nothing — that self-service path is real, shipped infrastructure
199
+ * (`reset-admin-password-self-verified.ts`, driven by both the `HOST_ADMIN_RESET_PASSWORD`
200
+ * boot-time incident-recovery hook and the `backfill-reset-admin-password.ts` CLI script), added
201
+ * specifically to let an operator recover a locked-out owner account out-of-band. Disabling has no
202
+ * legitimate self-case (an owner disabling itself only ever stands the workspace down), so
203
+ * `disablePrincipal` can refuse unconditionally; a credential rotation the owner performs on itself
204
+ * is ordinary hygiene and the documented recovery path, so only a MISMATCHED caller — the actual
205
+ * escalation shape — is refused here.
206
+ *
207
+ * Deliberately NOT -clamped (unlike `disablePrincipal`'s second guard): a reset changes a
208
+ * credential, it never changes `status`, so it can never drop the workspace's active owner-`*`
209
+ * count — headcount guard has nothing to protect here.
210
+ *
211
+ * `input.seededOwnerPrincipalId` is the caller's job to resolve (`await deps.ownerPrincipalId` at
212
+ * the route layer, mirroring `disablePrincipal`'s own contract above) — this function takes the
213
+ * already-resolved id so it stays a plain, directly-testable function.
214
+ *
215
+ * @complexity O(s) in the target's session count, s = active session rows to revoke — bounded by
216
+ * the same operator-managed-roster scale assumption every other identity list call here makes.
217
+ * @overallScore 100
218
+ * See docs/decisions/DR-002-grant-authority-and-admin-safety.md.
219
+ */
220
+ export async function resetUserPassword(required) {
221
+ const { deps, input } = required;
222
+ return inIdentityTransaction({
223
+ transactions: deps.repos.transactions,
224
+ workspaceId: input.workspaceId,
225
+ execute: async () => {
226
+ await assertCallerHasAnyPermission({
227
+ deps,
228
+ workspaceId: input.workspaceId,
229
+ callerPrincipalId: input.callerPrincipalId,
230
+ permissions: ["user.manage"],
231
+ });
232
+ const target = await deps.repos.users.findByPrincipalId({
233
+ workspaceId: input.workspaceId,
234
+ principalId: input.principalId,
235
+ });
236
+ if (!target)
237
+ throw new IdentityNotFoundError({ message: `user '${input.principalId}' was not found` });
238
+ if (target.principalId === input.seededOwnerPrincipalId && input.callerPrincipalId !== input.seededOwnerPrincipalId) {
239
+ // See docs/decisions/DR-002-grant-authority-and-admin-safety.md for the invariant behind this refusal; keep external identifiers out of caller-facing messages.
240
+ throw new OwnerRequiredError({ message: "the seeded owner's password can only be reset by the owner itself, never by another caller" });
241
+ }
242
+ await assertOwnerTargetMayBeModified({
243
+ deps,
244
+ workspaceId: input.workspaceId,
245
+ callerPrincipalId: input.callerPrincipalId,
246
+ principalId: input.principalId,
247
+ });
248
+ if (!input.password) {
249
+ throw new IdentityValidationError({ message: "password is required" });
250
+ }
251
+ // Same presence-and-upper-bound-only policy `createUser` applies (no minimum length). Both
252
+ // write paths must enforce it or neither does: a reset that accepted an empty password would be
253
+ // a strictly easier way to reach the state the create-side check exists to prevent. Never
254
+ // reaches `seed.ts`'s owner password or the login path — see `password-policy.ts`'s header for
255
+ // why.
256
+ const passwordError = validatePasswordPolicy({ password: input.password });
257
+ if (passwordError) {
258
+ throw new IdentityValidationError({ message: passwordError });
259
+ }
260
+ const passwordHash = await deps.hasher.hash({ password: input.password });
261
+ const updated = { ...target, passwordHash };
262
+ await deps.repos.users.save(updated);
263
+ const sessions = await deps.repos.sessions.listByPrincipalId({
264
+ workspaceId: input.workspaceId,
265
+ principalId: input.principalId,
266
+ });
267
+ const nowIso = kernelNowIso({ clock: deps.clock });
268
+ await Promise.all(sessions
269
+ .filter((session) => !session.revokedAt)
270
+ .map((session) => deps.repos.sessions.revoke({ workspaceId: input.workspaceId, id: session.id, revokedAt: nowIso })));
271
+ return { user: updated };
272
+ },
273
+ });
274
+ }
275
+ /**
276
+ * `UPDATE_ROLE` (feature.spec.md ). Gated by `role.manage`. A built-in target is
277
+ * refused `VALIDATION_ERROR` — extends existing undeletable/immutable/un-attachable-to
278
+ * rule to also cover un-renameable (same escalation shape: relabeling `viewer` to read as trusted
279
+ * is a social-engineering variant of what already blocks structurally).
280
+ *
281
+ * @complexity O(1) — one lookup, one save.
282
+ * @overallScore 100
283
+ * See docs/decisions/DR-002-grant-authority-and-admin-safety.md.
284
+ */
285
+ export async function updateRole(required) {
286
+ const { deps, input } = required;
287
+ await assertCallerHasAnyPermission({
288
+ deps,
289
+ workspaceId: input.workspaceId,
290
+ callerPrincipalId: input.callerPrincipalId,
291
+ permissions: ["role.manage"],
292
+ });
293
+ const target = await deps.repos.roles.findById({ workspaceId: input.workspaceId, id: input.roleId });
294
+ if (!target)
295
+ throw new IdentityNotFoundError({ message: `role '${input.roleId}' was not found` });
296
+ if (target.isBuiltin) {
297
+ // See docs/decisions/DR-002-grant-authority-and-admin-safety.md for the invariant behind this refusal; keep external identifiers out of caller-facing messages.
298
+ throw new IdentityValidationError({ message: "a built-in role cannot be renamed" });
299
+ }
300
+ const name = (input.name ?? "").trim();
301
+ if (!name)
302
+ throw new IdentityValidationError({ message: "role name is required" });
303
+ const updated = { ...target, name };
304
+ await deps.repos.roles.save(updated);
305
+ return { role: updated };
306
+ }
307
+ /**
308
+ * `UPDATE_POLICY` (feature.spec.md ). Gated by `role.manage`. A built-in or
309
+ * `is_frozen` target is refused `VALIDATION_ERROR` — the frozen-policy half mirrors
310
+ * `WRITE_POLICY_PERMISSION`'s existing refusal : an issuance snapshot's identity is as
311
+ * immutable as its permission set. At least one of `name`/`description` must be present.
312
+ *
313
+ * @complexity O(1) — one lookup, one save.
314
+ * @overallScore 100
315
+ * See docs/decisions/DR-002-grant-authority-and-admin-safety.md.
316
+ */
317
+ export async function updatePolicy(required, optional = {}) {
318
+ const { deps } = required;
319
+ const input = { ...required.input, ...optional };
320
+ await assertCallerHasAnyPermission({
321
+ deps,
322
+ workspaceId: input.workspaceId,
323
+ callerPrincipalId: input.callerPrincipalId,
324
+ permissions: ["role.manage"],
325
+ });
326
+ const target = await deps.repos.policies.findById({ workspaceId: input.workspaceId, id: input.policyId });
327
+ if (!target)
328
+ throw new IdentityNotFoundError({ message: `policy '${input.policyId}' was not found` });
329
+ if (target.isBuiltin || target.isFrozen) {
330
+ // See docs/decisions/DR-002-grant-authority-and-admin-safety.md for the invariant behind this refusal; keep external identifiers out of caller-facing messages.
331
+ throw new IdentityValidationError({ message: "a built-in or frozen policy cannot be renamed/re-described" });
332
+ }
333
+ if (input.name === undefined && input.description === undefined) {
334
+ throw new IdentityValidationError({ message: "at least one of name or description is required" });
335
+ }
336
+ const name = input.name !== undefined ? input.name.trim() : target.name;
337
+ if (!name)
338
+ throw new IdentityValidationError({ message: "policy name is required" });
339
+ const updated = {
340
+ ...target,
341
+ name,
342
+ description: input.description !== undefined ? input.description : target.description,
343
+ };
344
+ await deps.repos.policies.save(updated);
345
+ return { policy: updated };
346
+ }
347
+ /**
348
+ * `DELETE_ROLE` (feature.spec.md ). Gated by `role.manage`. A built-in
349
+ * target is refused `VALIDATION_ERROR` (checked before the reference count, matching
350
+ * stated precedence). A still-referenced target (≥1 `principal_roles` row) is refused
351
+ * `IdentityConflictError` (409 `RESOURCE_CONFLICT`) — the reference check and the delete happen in
352
+ * one identity transaction ( atomicity, same isolation requirement as
353
+ * `disablePrincipal`'s owner-count guard above). Concurrent grant writers use the same port so
354
+ * no assignment can appear between the reference check and deletion.
355
+ *
356
+ * @complexity O(a) in the role's assignment count (`principalRoles.listByRoleId`) — bounded by the
357
+ * same operator-managed-roster assumption every other identity list call makes.
358
+ * @overallScore 100
359
+ * See docs/decisions/DR-002-grant-authority-and-admin-safety.md.
360
+ */
361
+ export async function deleteRole(required) {
362
+ const { deps, input } = required;
363
+ return inIdentityTransaction({
364
+ transactions: deps.repos.transactions,
365
+ workspaceId: input.workspaceId,
366
+ execute: async () => {
367
+ await assertCallerHasAnyPermission({
368
+ deps,
369
+ workspaceId: input.workspaceId,
370
+ callerPrincipalId: input.callerPrincipalId,
371
+ permissions: ["role.manage"],
372
+ });
373
+ const target = await deps.repos.roles.findById({ workspaceId: input.workspaceId, id: input.roleId });
374
+ if (!target)
375
+ throw new IdentityNotFoundError({ message: `role '${input.roleId}' was not found` });
376
+ if (target.isBuiltin) {
377
+ // See docs/decisions/DR-002-grant-authority-and-admin-safety.md for the invariant behind this refusal; keep external identifiers out of caller-facing messages.
378
+ throw new IdentityValidationError({ message: "a built-in role cannot be deleted" });
379
+ }
380
+ const assignments = await deps.repos.principalRoles.listByRoleId({
381
+ workspaceId: input.workspaceId,
382
+ roleId: input.roleId,
383
+ });
384
+ if (assignments.length > 0) {
385
+ // See docs/decisions/DR-002-grant-authority-and-admin-safety.md for the invariant behind this refusal; keep external identifiers out of caller-facing messages.
386
+ throw new IdentityConflictError({ message: `role '${input.roleId}' is still assigned to ${assignments.length} principal(s)` });
387
+ }
388
+ await deps.repos.roles.delete({ workspaceId: input.workspaceId, id: input.roleId });
389
+ },
390
+ });
391
+ }
392
+ /**
393
+ * `DELETE_POLICY` (feature.spec.md ). Gated by `role.manage`. A
394
+ * built-in or `is_frozen` target is refused `VALIDATION_ERROR` (checked before the reference
395
+ * count). A still-referenced target (≥1 `role_policies` or `principal_policies` row) is refused
396
+ * `IdentityConflictError`. On success, cascades to the policy's OWN `policy_permissions` rows only
397
+ * (never a different policy's) before deleting the policy row itself. Both deletes and the
398
+ * reference guard share one transaction; failure rolls back the permission cascade.
399
+ *
400
+ * @complexity O(r + p) — role-reference count + principal-reference count, both bounded by the same
401
+ * operator-managed-roster assumption every other identity list call makes.
402
+ * @overallScore 100
403
+ * See docs/decisions/DR-002-grant-authority-and-admin-safety.md.
404
+ */
405
+ export async function deletePolicy(required) {
406
+ const { deps, input } = required;
407
+ return inIdentityTransaction({
408
+ transactions: deps.repos.transactions,
409
+ workspaceId: input.workspaceId,
410
+ execute: async () => {
411
+ await assertCallerHasAnyPermission({
412
+ deps,
413
+ workspaceId: input.workspaceId,
414
+ callerPrincipalId: input.callerPrincipalId,
415
+ permissions: ["role.manage"],
416
+ });
417
+ const target = await deps.repos.policies.findById({ workspaceId: input.workspaceId, id: input.policyId });
418
+ if (!target)
419
+ throw new IdentityNotFoundError({ message: `policy '${input.policyId}' was not found` });
420
+ if (target.isBuiltin || target.isFrozen) {
421
+ // See docs/decisions/DR-002-grant-authority-and-admin-safety.md for the invariant behind this refusal; keep external identifiers out of caller-facing messages.
422
+ throw new IdentityValidationError({ message: "a built-in or frozen policy cannot be deleted" });
423
+ }
424
+ const [roleRefs, principalRefs] = await Promise.all([
425
+ deps.repos.rolePolicies.listByPolicyId({ workspaceId: input.workspaceId, policyId: input.policyId }),
426
+ deps.repos.principalPolicies.listByPolicyId({ workspaceId: input.workspaceId, policyId: input.policyId }),
427
+ ]);
428
+ const referenceCount = roleRefs.length + principalRefs.length;
429
+ if (referenceCount > 0) {
430
+ // See docs/decisions/DR-002-grant-authority-and-admin-safety.md for the invariant behind this refusal; keep external identifiers out of caller-facing messages.
431
+ throw new IdentityConflictError({ message: `policy '${input.policyId}' is still referenced by ${referenceCount} row(s)` });
432
+ }
433
+ await deps.repos.policyPermissions.deleteByPolicyId({ workspaceId: input.workspaceId, policyId: input.policyId });
434
+ await deps.repos.policies.delete({ workspaceId: input.workspaceId, id: input.policyId });
435
+ },
436
+ });
437
+ }
438
+ /**
439
+ * `WRITE_POLICY_PERMISSION` (feature.spec.md, state.spec §3). First HTTP
440
+ * implementation of a transition specified since v0.5.3 ( — this is a route, not a
441
+ * re-specification). Gated by `role.manage`. Refuses an unregistered permission
442
+ * (`PermissionUnknownError`), a built-in or `is_frozen` parent policy, and
443
+ * (via the shared `assertGrantClamp`) a permission the caller does not itself hold unconstrained
444
+ * (`GrantExceedsIssuerError`).
445
+ *
446
+ * @complexity O(assertGrantClamp) — see that function's own doc (resolveEffectivePermissions +
447
+ * O(1) local filtering, one permission).
448
+ * @overallScore 100
449
+ * See docs/decisions/DR-002-grant-authority-and-admin-safety.md.
450
+ */
451
+ export async function writePolicyPermission(required, optional = {}) {
452
+ const { deps } = required;
453
+ const input = { ...required.input, ...optional };
454
+ return inIdentityTransaction({
455
+ transactions: deps.repos.transactions,
456
+ workspaceId: input.workspaceId,
457
+ execute: async () => {
458
+ await assertCallerHasAnyPermission({
459
+ deps,
460
+ workspaceId: input.workspaceId,
461
+ callerPrincipalId: input.callerPrincipalId,
462
+ permissions: ["role.manage"],
463
+ });
464
+ if (!isKnownPermission({ id: input.permission })) {
465
+ throw new PermissionUnknownError({ message: `permission '${input.permission}' is not in the registered catalog` });
466
+ }
467
+ const policy = await deps.repos.policies.findById({ workspaceId: input.workspaceId, id: input.policyId });
468
+ if (!policy)
469
+ throw new IdentityNotFoundError({ message: `policy '${input.policyId}' was not found` });
470
+ if (policy.isBuiltin || policy.isFrozen) {
471
+ // See docs/decisions/DR-002-grant-authority-and-admin-safety.md for the invariant behind this refusal; keep external identifiers out of caller-facing messages.
472
+ throw new IdentityValidationError({ message: "cannot write a permission onto a built-in or frozen policy" });
473
+ }
474
+ await assertGrantClamp({
475
+ deps,
476
+ workspaceId: input.workspaceId,
477
+ callerPrincipalId: input.callerPrincipalId,
478
+ conferredPermissions: [input.permission],
479
+ });
480
+ const policyPermission = {
481
+ id: deps.idGen.newId(),
482
+ workspaceId: input.workspaceId,
483
+ policyId: input.policyId,
484
+ permission: input.permission,
485
+ resourceType: input.resourceType ?? null,
486
+ constraintJson: input.constraintJson ?? null,
487
+ };
488
+ await deps.repos.policyPermissions.save(policyPermission);
489
+ return { policyPermission };
490
+ },
491
+ });
492
+ }
493
+ /**
494
+ * `REMOVE_POLICY_PERMISSION` — the inverse of {@link writePolicyPermission}, and the
495
+ * transition whose absence made a policy's permission set append-only: the only way to shrink one
496
+ * was `deletePolicy` + recreate, which blocks outright the moment anything references the
497
+ * policy.
498
+ *
499
+ * Gated by `role.manage`, the same permission `writePolicyPermission` and `deletePolicy` require.
500
+ * Deliberately NOT clamped by `assertGrantClamp` : the clamp exists to stop a caller
501
+ * conferring authority it does not itself hold, and removal confers nothing — it is a strict
502
+ * de-escalation. Requiring the clamp here would mean an admin who cannot grant `content.write`
503
+ * also cannot take it back, which is the wrong direction for a safety rule. This is strictly less
504
+ * powerful than the already-unclamped `deletePolicy`, which drops EVERY permission row the policy
505
+ * has.
506
+ *
507
+ * Refuses a built-in or `is_frozen` parent policy, checked BEFORE the row lookup so
508
+ * a frozen policy reports why it is frozen rather than leaking whether the row exists. A row that
509
+ * is absent — or that exists but belongs to a DIFFERENT policy — is one and the same
510
+ * `IdentityNotFoundError`: membership is proven from `listByPolicyId`'s result, so a caller can
511
+ * never delete across a policy boundary by guessing an id.
512
+ *
513
+ * @complexity O(n) in the policy's own permission-row count (one `listByPolicyId`) — bounded by the
514
+ * same operator-managed-roster assumption every other identity list call makes.
515
+ * See docs/decisions/DR-002-grant-authority-and-admin-safety.md.
516
+ */
517
+ export async function removePolicyPermission(required) {
518
+ const { deps, input } = required;
519
+ return inIdentityTransaction({
520
+ transactions: deps.repos.transactions,
521
+ workspaceId: input.workspaceId,
522
+ execute: async () => {
523
+ await assertCallerHasAnyPermission({
524
+ deps,
525
+ workspaceId: input.workspaceId,
526
+ callerPrincipalId: input.callerPrincipalId,
527
+ permissions: ["role.manage"],
528
+ });
529
+ const policy = await deps.repos.policies.findById({ workspaceId: input.workspaceId, id: input.policyId });
530
+ if (!policy)
531
+ throw new IdentityNotFoundError({ message: `policy '${input.policyId}' was not found` });
532
+ if (policy.isBuiltin || policy.isFrozen) {
533
+ // See docs/decisions/DR-002-grant-authority-and-admin-safety.md for the invariant behind this refusal; keep external identifiers out of caller-facing messages.
534
+ throw new IdentityValidationError({ message: "cannot remove a permission from a built-in or frozen policy" });
535
+ }
536
+ const rows = await deps.repos.policyPermissions.listByPolicyId({
537
+ workspaceId: input.workspaceId,
538
+ policyId: input.policyId,
539
+ });
540
+ const target = rows.find((row) => row.id === input.policyPermissionId);
541
+ if (!target) {
542
+ throw new IdentityNotFoundError({ message: `policy permission '${input.policyPermissionId}' was not found on policy '${input.policyId}'` });
543
+ }
544
+ await deps.repos.policyPermissions.delete({ workspaceId: input.workspaceId, id: target.id });
545
+ },
546
+ });
547
+ }
548
+ /**
549
+ * Count `active` principals in `workspaceId` that hold the owner wildcard `*` ( own
550
+ * definition of "owner-`*` principal"). O(n) in the workspace's principal count, n bounded by the
551
+ * same operator-managed-roster assumption `identity/INFO.md` documents.
552
+ * See docs/decisions/DR-002-grant-authority-and-admin-safety.md.
553
+ */
554
+ async function countActiveOwnerWildcardPrincipals(required) {
555
+ const { deps, workspaceId } = required;
556
+ const principals = await deps.repos.principals.list({ workspaceId });
557
+ const activePrincipals = principals.filter((principal) => principal.status === "active");
558
+ const flags = await Promise.all(activePrincipals.map((principal) => principalHoldsOwnerWildcard({ deps, workspaceId, principalId: principal.id })));
559
+ return flags.filter(Boolean).length;
560
+ }
561
+ //# sourceMappingURL=admin-crud-service.js.map
@@ -0,0 +1,58 @@
1
+ /**
2
+ * @file The enforcing boundary parser for identity agent-tool input — the identity domain's
3
+ * analogue of `features/content-types/field-defs.ts`.
4
+ *
5
+ * Deliberate divergence from content-types, disclosed:
6
+ * content-types hand-authors its published `inputSchema` and hand-authors `field-defs.ts`
7
+ * separately, then pins the two together with a fixture corpus
8
+ * (`agent-tools.schema-agreement.unit.test.ts`) because deriving one from the other would have
9
+ * needed either a JSON Schema library this project does not depend on or a code generator. That
10
+ * reasoning does not carry over here: identity's schemas use a small keyword subset and describe
11
+ * flat objects of strings, so this file INTERPRETS that published schema directly rather than
12
+ * duplicating it. Sharing the artifact prevents metadata drift; the independent agreement tests
13
+ * still verify interpreter semantics, including Unicode length and unsupported schema shapes.
14
+ *
15
+ * What remains genuinely testable, and is tested in
16
+ * `__tests__/agent-tools.schema-agreement.test.ts`: that this interpreter actually implements
17
+ * every keyword the catalog uses (an unknown keyword fails loudly rather than being ignored, which
18
+ * is one way a schema could still promise something unenforced), and that the wired handlers
19
+ * really route their input through here rather than reading `ctx.input` directly.
20
+ *
21
+ * Architectural role:
22
+ * `identity` library, pure. No I/O, no enforcement of permissions — this is shape validation only.
23
+ * Returns a `Result` rather than throwing so the caller decides how a rejection is surfaced.
24
+ */
25
+ export type IdentityToolInputResult = {
26
+ ok: true;
27
+ value: Readonly<Record<string, string>>;
28
+ } | {
29
+ ok: false;
30
+ error: Error;
31
+ };
32
+ /**
33
+ * Validates `input` against a tool's own published `inputSchema` and returns the extracted string
34
+ * values.
35
+ *
36
+ * Every identity tool's input is a closed, flat object whose properties are all strings, so the
37
+ * return type is narrowed to `Record<string, string>` — a caller reads `value.principalId` without
38
+ * re-checking the type, which is what keeps the handlers in `tool-registrations.ts` free of ad hoc
39
+ * `typeof` guards that could disagree with the schema.
40
+ *
41
+ * Rejection messages name the offending KEY and the rule it broke, and never echo the offending
42
+ * VALUE: a tool input carries operator content (a username, an email) and these messages travel
43
+ * back to the model and into the tool-attempt audit trail.
44
+ *
45
+ * @param required.schema - The tool's published `inputSchema`, used as the contract itself.
46
+ * @param required.input - The raw `ctx.input`, of any shape.
47
+ * @returns `{ok:true, value}` with each declared property present in `input` extracted, or
48
+ * `{ok:false, error}` naming the first violation found.
49
+ * @throws {Error} Only if the schema itself is uninterpretable — a developer error, distinct from
50
+ * a bad input, and therefore not folded into the `Result`.
51
+ * @complexity O(p + k + s) in declared properties, own input keys, and string code points.
52
+ * @overallScore 100
53
+ */
54
+ export declare function parseIdentityToolInput(required: {
55
+ schema: Readonly<Record<string, unknown>>;
56
+ input: unknown;
57
+ }): IdentityToolInputResult;
58
+ //# sourceMappingURL=agent-tool-input.d.ts.map