@jini-ai/user-management 0.3.0 → 0.3.2

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 (38) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/dist/core/principal-kind-policy.d.ts +1 -1
  3. package/dist/core/principal-kind-policy.js +1 -1
  4. package/dist/core/runtime-ports.d.ts +20 -0
  5. package/dist/core/types.d.ts +4 -1
  6. package/dist/core/types.js +5 -1
  7. package/dist/react/index.d.ts +7 -0
  8. package/dist/react/index.js +4 -0
  9. package/dist/react/members/Members.d.ts +28 -1
  10. package/dist/react/members/Members.js +15 -11
  11. package/dist/react/members/hooks/use-members.hooks.d.ts +46 -2
  12. package/dist/react/members/hooks/use-members.hooks.js +82 -54
  13. package/dist/react/members/rules.d.ts +31 -4
  14. package/dist/react/members/rules.js +17 -5
  15. package/dist/react/models.d.ts +5 -0
  16. package/dist/react/ports.d.ts +2 -0
  17. package/dist/react/testing.js +1 -1
  18. package/dist/react/users/ChangeUsernameDialog.d.ts +8 -0
  19. package/dist/react/users/ChangeUsernameDialog.js +8 -0
  20. package/dist/react/users/Users.d.ts +107 -7
  21. package/dist/react/users/Users.js +108 -43
  22. package/dist/react/users/hooks/use-change-username.hooks.d.ts +27 -0
  23. package/dist/react/users/hooks/use-change-username.hooks.js +53 -0
  24. package/dist/react/users/hooks/use-reset-password-fields.hooks.d.ts +33 -1
  25. package/dist/react/users/hooks/use-reset-password-fields.hooks.js +16 -1
  26. package/dist/react/users/hooks/use-users.hooks.d.ts +129 -3
  27. package/dist/react/users/hooks/use-users.hooks.js +223 -23
  28. package/dist/react/users/rules.d.ts +116 -8
  29. package/dist/react/users/rules.js +112 -6
  30. package/dist/server/admin-crud-service.d.ts +6 -4
  31. package/dist/server/admin-crud-service.js +49 -6
  32. package/dist/server/agent-tool-registrations.d.ts +1 -0
  33. package/dist/server/agent-tool-registrations.js +14 -0
  34. package/dist/server/agent-tools.js +11 -0
  35. package/dist/server/auth-service.d.ts +3 -1
  36. package/dist/server/seed.d.ts +7 -4
  37. package/dist/server/seed.js +15 -6
  38. package/package.json +17 -12
@@ -1,33 +1,141 @@
1
1
  import type { AdminIdentityUser } from "../models.js";
2
- import type { Translate } from "@jini-ai/ui/panel-kit";
2
+ import type { Translate, QueryKey } from "@jini-ai/ui/panel-kit";
3
3
  import type { RowMenuItem } from "@jini-ai/admin/react";
4
- import type { QueryKey } from "@jini-ai/ui/panel-kit";
4
+ /**
5
+ * @file Pure logic for the `users` feature — everything that computes a value rather than
6
+ * rendering one. Follows the `posts/rules.ts` convention: no React import, no hooks, directly
7
+ * testable.
8
+ *
9
+ * Moved here from `Users.tsx`: the screen's own `describeApiError` override (a branch per server
10
+ * error `code`), the row-menu item builder (a branch on `user.status` plus the confirm-vs-immediate
11
+ * split for Disable/Enable), and the role/policy grant label formatter (a branch on whether the
12
+ * user holds any grants of that kind). All three were previously closures or free functions inside
13
+ * the component, reachable only by rendering the full screen.
14
+ *
15
+ * `KEYS` (fetch-query migration, 2026-08-12): one identity for the combined users+roles+policies
16
+ * read — matches the pre-migration `reload()`, which always refetched all three together.
17
+ */
5
18
  export declare const KEYS: {
6
19
  list: QueryKey;
7
20
  };
8
- export declare function describeApiError({ error, fallback, translate }: {
21
+ /** Server error `code` -> a plain-language prefix (SPEC-006 errors.spec.md §2), layered on the
22
+ * shared default (`lib/api.ts`'s `describeApiError`) — this screen's `RESOURCE_CONFLICT` means
23
+ * "username already in use", a different meaning than `Roles.tsx`'s "still referenced" or
24
+ * `Workspace.tsx`'s "slug already taken" for the same code (audit cross-cutting finding #2 —
25
+ * deliberately not unified into one table). `VALIDATION_ERROR` stays a dedicated branch rather than
26
+ * joining the table above: its message comes from the error itself (`e.message`), not a fixed
27
+ * string, so it isn't a value a plain lookup can hold.
28
+ *
29
+ * `locale` (C4 fix, 2026-09-20): every branch here used to return its English literal directly,
30
+ * leaking English into every non-`en` locale regardless of the caller's own translated fallback —
31
+ * the fallback was already threaded through `t()` at each call site, but these overrides were not.
32
+ * Each literal is now also a key into `users-i18n.ts`'s `USERS_DICT`. */
33
+ export declare function describeUsersError({ error, fallback, translate }: {
9
34
  error: unknown;
10
35
  fallback: string;
11
36
  translate: Translate;
12
- }): string;
37
+ }, _optional?: Record<string, never>): string;
38
+ export { describeUsersError as describeApiError };
39
+ /** The callbacks a row menu needs. Passed in rather than imported, so this module stays free of
40
+ * state, and so a test can assert exactly which one a given row wires up (same convention as
41
+ * `posts/rules.ts`'s `PostRowMenuHandlers`). */
13
42
  export interface UserRowMenuHandlers {
43
+ /** Asked only when `user.status === "active"` — opens the confirm dialog rather than acting. */
14
44
  onRequestDisable: (user: AdminIdentityUser) => void;
45
+ /** Asked only when `user.status !== "active"` — fires immediately, no confirm (Enable was never
46
+ * confirm-gated). */
15
47
  onEnable: (user: AdminIdentityUser) => void;
16
48
  onManage: (user: AdminIdentityUser) => void;
17
49
  onResetPassword: (user: AdminIdentityUser) => void;
50
+ onChangeUsername?: ((user: AdminIdentityUser) => void) | undefined;
51
+ /** Delete-user plan v2 (2026-09-24) — always opens the confirm dialog, never fires immediately:
52
+ * unlike Enable, "Delete" moves the user to the Trash, a destructive-looking action even though
53
+ * it is recoverable there for 60 days. Only asked for when `userRowMenuItems`'s own `canDelete`
54
+ * is true — see that function's doc comment. */
18
55
  onRequestDelete: (user: AdminIdentityUser) => void;
19
56
  }
20
- export declare function userRowMenuItems({ user, toggleSaving, handlers, translate, canDelete }: {
57
+ export interface UserAccountCapabilities {
58
+ canDelete: boolean;
59
+ canDisable: boolean;
60
+ canEnable: boolean;
61
+ canResetPassword: boolean;
62
+ }
63
+ /** Courtesy mirror of the server target rule. Unknown identity/roles fail closed. */
64
+ export declare function userAccountCapabilities(required: {
65
+ user: AdminIdentityUser;
66
+ callerPrincipalId: string | null;
67
+ callerIsOwner: boolean;
68
+ canManageUserTrash: boolean;
69
+ roles: ReadonlyMap<string, {
70
+ name: string;
71
+ isBuiltin: boolean;
72
+ }>;
73
+ }, _optional?: Record<string, never>): UserAccountCapabilities;
74
+ export declare function isOwnerAccount(required: {
75
+ user: AdminIdentityUser;
76
+ roles: ReadonlyMap<string, {
77
+ name: string;
78
+ isBuiltin: boolean;
79
+ }>;
80
+ }, _optional?: Record<string, never>): boolean;
81
+ /**
82
+ * `RowMenu` items for one user row — matches `Posts.tsx`/`Pages.tsx`'s three-dot menu shape:
83
+ * Disable/Enable, Manage, Reset password, and — delete-user plan v2 (2026-09-24) — Delete, appended
84
+ * only when `canDelete` is true. The server gate (owner or the built-in `admin` role,
85
+ * `delete-user-service.ts`'s `callerMayManageUserTrash`) is the real boundary; `canDelete` is this
86
+ * row's own affordance-hiding mirror of it, computed once per render from `/auth/me` by
87
+ * `use-users.hooks.ts` — see that hook's `canManageUserTrash` state for where it comes from.
88
+ *
89
+ * Disable/Enable share a single "toggle" item (label follows status, same shape as
90
+ * `Redirects.tsx`'s own toggle item) — Disable confirms via the modal below; Enable fires
91
+ * immediately, matching this screen's existing behavior (Enable was never confirm-gated). The
92
+ * `toggleSaving` guard reproduces the original inline check (`if (toggleSavingId) return`) against
93
+ * whatever row action is currently in flight, read fresh at click time.
94
+ *
95
+ * "Manage" always reads "Manage", never "Close": the item still toggles the expanded panel
96
+ * (`onManage` — closing it again by selecting "Manage" a second time still works exactly as it did
97
+ * as a standalone button), but a `RowMenu` item disappears the instant it is selected, so a label
98
+ * that flips to "Close" is never actually visible mid-interaction — it would only ever describe a
99
+ * state the operator cannot see while the menu that shows it is open. A static label sidesteps that
100
+ * without losing any capability.
101
+ *
102
+ * "Delete" always opens the confirm dialog (`onRequestDelete`), matching Disable's shape rather than
103
+ * Enable's immediate-fire one — see `UserRowMenuHandlers.onRequestDelete`'s own doc comment.
104
+ *
105
+ * @complexity Time/space: O(1) — at most four entries, no iteration.
106
+ */
107
+ export declare function userRowMenuItems({ user, toggleSaving, handlers, translate, canDelete, capabilities }: {
21
108
  user: AdminIdentityUser;
22
109
  toggleSaving: boolean;
23
110
  handlers: UserRowMenuHandlers;
24
111
  translate: Translate;
25
112
  canDelete: boolean;
26
- }): RowMenuItem[];
27
- export declare function formatGrantLabel({ ids, byId }: {
113
+ capabilities?: boolean | UserAccountCapabilities | undefined;
114
+ }, _optional?: Record<string, never>): RowMenuItem[];
115
+ /**
116
+ * The role or policy names a user holds, for the "Roles"/"Policies" table cells — `null` when the
117
+ * user holds none, which the caller renders as the `muted-cell` "none" fallback (a plain ternary on
118
+ * this already-computed value, per this feature's extraction rule).
119
+ *
120
+ * A grant id with no matching entry in `byId` (a role/policy deleted out from under a still-held
121
+ * grant) falls back to rendering the raw id rather than dropping it silently — the operator sees
122
+ * something is wrong instead of an undercount with no explanation.
123
+ *
124
+ * @complexity Time: O(n) in the number of granted ids; space: O(n) for the joined string.
125
+ */
126
+ export declare function grantOptionLabel(required: {
127
+ option: {
128
+ name: string;
129
+ isBuiltin: boolean;
130
+ };
131
+ translate: (key: string) => string;
132
+ }, _optional?: Record<string, never>): string;
133
+ export declare function formatGrantLabel({ ids, byId, translate }: {
28
134
  ids: readonly string[];
29
135
  byId: ReadonlyMap<string, {
30
136
  name: string;
137
+ isBuiltin?: boolean;
31
138
  }>;
32
- }): string | null;
139
+ translate?: Translate | undefined;
140
+ }, _optional?: Record<string, never>): string | null;
33
141
  //# sourceMappingURL=rules.d.ts.map
@@ -1,20 +1,101 @@
1
1
  import { describeIdentityError } from "../errors.js";
2
+ /**
3
+ * @file Pure logic for the `users` feature — everything that computes a value rather than
4
+ * rendering one. Follows the `posts/rules.ts` convention: no React import, no hooks, directly
5
+ * testable.
6
+ *
7
+ * Moved here from `Users.tsx`: the screen's own `describeApiError` override (a branch per server
8
+ * error `code`), the row-menu item builder (a branch on `user.status` plus the confirm-vs-immediate
9
+ * split for Disable/Enable), and the role/policy grant label formatter (a branch on whether the
10
+ * user holds any grants of that kind). All three were previously closures or free functions inside
11
+ * the component, reachable only by rendering the full screen.
12
+ *
13
+ * `KEYS` (fetch-query migration, 2026-08-12): one identity for the combined users+roles+policies
14
+ * read — matches the pre-migration `reload()`, which always refetched all three together.
15
+ */
2
16
  export const KEYS = {
3
17
  list: ["users-roles-policies"],
4
18
  };
19
+ /** Server error `code` -> a plain-language prefix, for every code on this screen whose message is
20
+ * a fixed string. Keyed by the same closed set of `ApiError` codes the old if-chain checked, in a
21
+ * flat lookup rather than sequential branches — this is what actually lowered the function's
22
+ * cognitive score (11 -> under the ceiling): the codes are a closed set of *literal string* keys,
23
+ * not a discriminated union, so there is no TypeScript exhaustiveness to lose by using a table
24
+ * (contrast `translateRunAgentPayload`'s `switch`, which stays a `switch` for exactly that reason).
25
+ * Values are the English source strings — `t()`'s own keys — not yet localized; `describeApiError`
26
+ * below is what translates them. */
5
27
  const STATIC_ERROR_MESSAGES = {
6
28
  GRANT_EXCEEDS_ISSUER: "You cannot grant a permission you do not hold.",
7
29
  FORBIDDEN: "You do not have permission to do that.",
8
30
  RESOURCE_CONFLICT: "That username is already in use.",
9
31
  OWNER_REQUIRED: "The workspace must keep at least one active owner.",
32
+ // Delete-user plan v2 (2026-09-24), Slice 3/decision 7 — the DELETE route's own 409s.
10
33
  SELF_DELETE: "You cannot delete your own account.",
11
34
  USER_IN_TRASH: "This user is in the Trash; restore them first.",
12
35
  USERNAME_IN_TRASH: "A user with this username is in the Trash; restore or delete them permanently first.",
13
36
  };
14
- export function describeApiError({ error, fallback, translate }) {
37
+ /** Server error `code` -> a plain-language prefix (SPEC-006 errors.spec.md §2), layered on the
38
+ * shared default (`lib/api.ts`'s `describeApiError`) — this screen's `RESOURCE_CONFLICT` means
39
+ * "username already in use", a different meaning than `Roles.tsx`'s "still referenced" or
40
+ * `Workspace.tsx`'s "slug already taken" for the same code (audit cross-cutting finding #2 —
41
+ * deliberately not unified into one table). `VALIDATION_ERROR` stays a dedicated branch rather than
42
+ * joining the table above: its message comes from the error itself (`e.message`), not a fixed
43
+ * string, so it isn't a value a plain lookup can hold.
44
+ *
45
+ * `locale` (C4 fix, 2026-09-20): every branch here used to return its English literal directly,
46
+ * leaking English into every non-`en` locale regardless of the caller's own translated fallback —
47
+ * the fallback was already threaded through `t()` at each call site, but these overrides were not.
48
+ * Each literal is now also a key into `users-i18n.ts`'s `USERS_DICT`. */
49
+ export function describeUsersError({ error, fallback, translate }, _optional = {}) {
15
50
  return describeIdentityError({ error, fallback, translate }, { messages: STATIC_ERROR_MESSAGES });
16
51
  }
17
- export function userRowMenuItems({ user, toggleSaving, handlers, translate, canDelete }) {
52
+ export { describeUsersError as describeApiError };
53
+ /** Courtesy mirror of the server target rule. Unknown identity/roles fail closed. */
54
+ export function userAccountCapabilities(required, _optional = {}) {
55
+ const { user, callerPrincipalId, callerIsOwner, canManageUserTrash, roles } = required;
56
+ const known = callerPrincipalId !== null;
57
+ const protectedAccount = user.isProtectedAccount ?? (user.roleIds.some(id => {
58
+ const role = roles.get(id);
59
+ return !role || (role.isBuiltin && (role.name === "admin" || role.name === "owner"));
60
+ }));
61
+ const mayAct = known && (callerIsOwner || !protectedAccount);
62
+ const destructive = mayAct && callerPrincipalId !== user.principalId;
63
+ return { canDelete: destructive && canManageUserTrash, canDisable: destructive,
64
+ canEnable: mayAct, canResetPassword: mayAct };
65
+ }
66
+ export function isOwnerAccount(required, _optional = {}) {
67
+ return required.user.isOwner ?? required.user.roleIds.some(id => {
68
+ const role = required.roles.get(id);
69
+ return role?.isBuiltin === true && role.name === "owner";
70
+ });
71
+ }
72
+ /**
73
+ * `RowMenu` items for one user row — matches `Posts.tsx`/`Pages.tsx`'s three-dot menu shape:
74
+ * Disable/Enable, Manage, Reset password, and — delete-user plan v2 (2026-09-24) — Delete, appended
75
+ * only when `canDelete` is true. The server gate (owner or the built-in `admin` role,
76
+ * `delete-user-service.ts`'s `callerMayManageUserTrash`) is the real boundary; `canDelete` is this
77
+ * row's own affordance-hiding mirror of it, computed once per render from `/auth/me` by
78
+ * `use-users.hooks.ts` — see that hook's `canManageUserTrash` state for where it comes from.
79
+ *
80
+ * Disable/Enable share a single "toggle" item (label follows status, same shape as
81
+ * `Redirects.tsx`'s own toggle item) — Disable confirms via the modal below; Enable fires
82
+ * immediately, matching this screen's existing behavior (Enable was never confirm-gated). The
83
+ * `toggleSaving` guard reproduces the original inline check (`if (toggleSavingId) return`) against
84
+ * whatever row action is currently in flight, read fresh at click time.
85
+ *
86
+ * "Manage" always reads "Manage", never "Close": the item still toggles the expanded panel
87
+ * (`onManage` — closing it again by selecting "Manage" a second time still works exactly as it did
88
+ * as a standalone button), but a `RowMenu` item disappears the instant it is selected, so a label
89
+ * that flips to "Close" is never actually visible mid-interaction — it would only ever describe a
90
+ * state the operator cannot see while the menu that shows it is open. A static label sidesteps that
91
+ * without losing any capability.
92
+ *
93
+ * "Delete" always opens the confirm dialog (`onRequestDelete`), matching Disable's shape rather than
94
+ * Enable's immediate-fire one — see `UserRowMenuHandlers.onRequestDelete`'s own doc comment.
95
+ *
96
+ * @complexity Time/space: O(1) — at most four entries, no iteration.
97
+ */
98
+ export function userRowMenuItems({ user, toggleSaving, handlers, translate, canDelete, capabilities = canDelete }, _optional = {}) {
18
99
  const items = [
19
100
  {
20
101
  key: "toggle",
@@ -43,7 +124,13 @@ export function userRowMenuItems({ user, toggleSaving, handlers, translate, canD
43
124
  onSelect: () => handlers.onResetPassword(user),
44
125
  },
45
126
  ];
46
- if (canDelete) {
127
+ const permissions = typeof capabilities === "boolean"
128
+ ? { canDelete: capabilities, canDisable: true, canEnable: true, canResetPassword: true }
129
+ : capabilities;
130
+ if (handlers.onChangeUsername) {
131
+ items.push({ key: "change-username", label: translate("Change username"), onSelect: () => handlers.onChangeUsername?.(user) });
132
+ }
133
+ if (permissions.canDelete) {
47
134
  items.push({
48
135
  key: "delete",
49
136
  label: translate("Delete"),
@@ -51,11 +138,30 @@ export function userRowMenuItems({ user, toggleSaving, handlers, translate, canD
51
138
  onSelect: () => handlers.onRequestDelete(user),
52
139
  });
53
140
  }
54
- return items;
141
+ return items.filter(item => item.key === "toggle"
142
+ ? (user.status === "active" ? permissions.canDisable : permissions.canEnable)
143
+ : item.key !== "reset-password" || permissions.canResetPassword);
144
+ }
145
+ /**
146
+ * The role or policy names a user holds, for the "Roles"/"Policies" table cells — `null` when the
147
+ * user holds none, which the caller renders as the `muted-cell` "none" fallback (a plain ternary on
148
+ * this already-computed value, per this feature's extraction rule).
149
+ *
150
+ * A grant id with no matching entry in `byId` (a role/policy deleted out from under a still-held
151
+ * grant) falls back to rendering the raw id rather than dropping it silently — the operator sees
152
+ * something is wrong instead of an undercount with no explanation.
153
+ *
154
+ * @complexity Time: O(n) in the number of granted ids; space: O(n) for the joined string.
155
+ */
156
+ export function grantOptionLabel(required, _optional = {}) {
157
+ return required.option.isBuiltin && required.option.name === "owner" ? required.translate("Owner") : required.option.name;
55
158
  }
56
- export function formatGrantLabel({ ids, byId }) {
159
+ export function formatGrantLabel({ ids, byId, translate = (key) => key }, _optional = {}) {
57
160
  if (ids.length === 0)
58
161
  return null;
59
- return ids.map((id) => byId.get(id)?.name ?? id).join(", ");
162
+ return ids.map(id => {
163
+ const role = byId.get(id);
164
+ return role?.isBuiltin && role.name === "owner" ? translate("Owner") : role?.name ?? id;
165
+ }).join(", ");
60
166
  }
61
167
  //# sourceMappingURL=rules.js.map
@@ -75,12 +75,13 @@ export declare function enablePrincipal(required: {
75
75
  }>;
76
76
  /**
77
77
  * `UPDATE_USER` (feature.spec.md ). Gated by `user.manage`; existing self-profile edits also
78
- * accept `member.manage`. Only owners may edit owner targets — `email` only; `username`/`password` are not
79
- * this transition's concern ( respectively). An absent or empty-string `email`
78
+ * accept `member.manage`. Username changes require `user.manage`, including self edits.
79
+ * Only owners may edit owner targets. Password is not this transition's concern.
80
+ * An absent or empty-string `email`
80
81
  * clears the stored value to `undefined` ( — no distinct "blank but present" state).
81
82
  *
82
- * @complexity O(1) — one lookup, one save.
83
- * @overallScore 100
83
+ * @complexity O(n) normalization for n username characters; fixed repo/Trash reads, one user save
84
+ * and one audit append for an actual rename, all inside the shared identity transaction.
84
85
  * See docs/decisions/DR-002-grant-authority-and-admin-safety.md.
85
86
  */
86
87
  export declare function updateUser(required: {
@@ -92,6 +93,7 @@ export declare function updateUser(required: {
92
93
  };
93
94
  }, optional?: {
94
95
  email?: string | undefined;
96
+ username?: string | undefined;
95
97
  }): Promise<{
96
98
  user: UserRecord;
97
99
  }>;
@@ -2,6 +2,7 @@ import { nowIso as kernelNowIso } from "@jini-ai/core/primitives";
2
2
  import { inIdentityTransaction } from "./identity-transaction.js";
3
3
  import { assertCallerHasAnyPermission, assertGrantClamp, assertOwnerTargetMayBeModified, principalHoldsOwnerWildcard } from "./grant-service.js";
4
4
  import { validatePasswordPolicy } from "./password-policy.js";
5
+ import { normalizeUsername } from "./username.js";
5
6
  import { isKnownPermission } from "../core/permissions.js";
6
7
  import { principalKindMayExercisePermission } from "../core/principal-kind-policy.js";
7
8
  import { IdentityConflictError, IdentityNotFoundError, IdentityValidationError, OwnerRequiredError, PermissionUnknownError, } from "../core/types.js";
@@ -138,12 +139,13 @@ export async function enablePrincipal(required) {
138
139
  }
139
140
  /**
140
141
  * `UPDATE_USER` (feature.spec.md ). Gated by `user.manage`; existing self-profile edits also
141
- * accept `member.manage`. Only owners may edit owner targets — `email` only; `username`/`password` are not
142
- * this transition's concern ( respectively). An absent or empty-string `email`
142
+ * accept `member.manage`. Username changes require `user.manage`, including self edits.
143
+ * Only owners may edit owner targets. Password is not this transition's concern.
144
+ * An absent or empty-string `email`
143
145
  * clears the stored value to `undefined` ( — no distinct "blank but present" state).
144
146
  *
145
- * @complexity O(1) — one lookup, one save.
146
- * @overallScore 100
147
+ * @complexity O(n) normalization for n username characters; fixed repo/Trash reads, one user save
148
+ * and one audit append for an actual rename, all inside the shared identity transaction.
147
149
  * See docs/decisions/DR-002-grant-authority-and-admin-safety.md.
148
150
  */
149
151
  export async function updateUser(required, optional = {}) {
@@ -158,7 +160,7 @@ export async function updateUser(required, optional = {}) {
158
160
  workspaceId: input.workspaceId,
159
161
  callerPrincipalId: input.callerPrincipalId,
160
162
  // Preserve existing self-profile access without letting member.manage edit other operators.
161
- permissions: input.callerPrincipalId === input.principalId
163
+ permissions: input.username === undefined && input.callerPrincipalId === input.principalId
162
164
  ? ["user.manage", "member.manage"]
163
165
  : ["user.manage"],
164
166
  });
@@ -174,12 +176,53 @@ export async function updateUser(required, optional = {}) {
174
176
  callerPrincipalId: input.callerPrincipalId,
175
177
  principalId: input.principalId,
176
178
  });
177
- const updated = { ...target, email: input.email ? input.email : undefined };
179
+ const username = input.username === undefined ? target.username : await resolveUpdatedUsername({ deps, input, target });
180
+ // Preserve the old email setter contract, while a username-only call retains email.
181
+ const email = input.username !== undefined && !Object.hasOwn(optional, "email")
182
+ ? target.email : (input.email || undefined);
183
+ const updated = { ...target, username, email };
178
184
  await deps.repos.users.save(updated);
185
+ if (username !== target.username) {
186
+ // Renaming never changes the principal id or credentials: existing sessions stay valid.
187
+ await deps.usernameChanges.record({ workspaceId: input.workspaceId, principalId: input.principalId,
188
+ callerPrincipalId: input.callerPrincipalId, previousUsername: target.username, username,
189
+ occurredAt: kernelNowIso({ clock: deps.clock }) });
190
+ }
179
191
  return { user: updated };
180
192
  },
181
193
  });
182
194
  }
195
+ /** Validate and reserve a normalized name inside the caller's identity transaction.
196
+ * Permission/owner gates run first, so conflicts never disclose accounts to unauthorized callers.
197
+ * @complexity O(n) normalization for n username characters; one indexed lookup and up to two Trash reads.
198
+ * Throws validation or conflict errors; performs no writes.
199
+ */
200
+ async function resolveUpdatedUsername(required, _optional = {}) {
201
+ const { deps, input, target } = required;
202
+ if (typeof input.username !== "string")
203
+ throw new IdentityValidationError({ message: "username must be a string" });
204
+ const username = normalizeUsername({ raw: input.username });
205
+ if (!username)
206
+ throw new IdentityValidationError({ message: "username must not be blank" });
207
+ // Missing host ports are internal wiring faults, not user input errors (HTTP 500, not 400).
208
+ if (!deps.usernameChanges)
209
+ throw new Error("username changes are not configured");
210
+ if (await deps.usernameChanges.isInTrash({ workspaceId: input.workspaceId, principalId: input.principalId })) {
211
+ throw new IdentityConflictError({ message: "this user is in the Trash; restore them first", code: "USER_IN_TRASH" });
212
+ }
213
+ if (username !== target.username) {
214
+ await deps.usernameChanges.assertSupported?.({ workspaceId: input.workspaceId, principalId: input.principalId });
215
+ }
216
+ // Trash keeps credential rows until purge, so the unfiltered lookup reserves those names too.
217
+ const existing = await deps.repos.users.findByUsername({ workspaceId: input.workspaceId, username });
218
+ if (existing && existing.principalId !== target.principalId) {
219
+ if (await deps.usernameChanges.isInTrash({ workspaceId: input.workspaceId, principalId: existing.principalId })) {
220
+ throw new IdentityConflictError({ message: "a user with this username is in the Trash; restore or delete them permanently first", code: "USERNAME_IN_TRASH" });
221
+ }
222
+ throw new IdentityConflictError({ message: `username '${username}' is already in use`, code: "RESOURCE_CONFLICT" });
223
+ }
224
+ return username;
225
+ }
183
226
  /**
184
227
  * `RESET_USER_PASSWORD` (feature.spec.md ). Gated by **`user.manage`** only
185
228
  * (resetting a credential is at least as sensitive as disabling the account, own reasoning). Hashes the new password identically to `CREATE_USER` (argon2id,
@@ -40,6 +40,7 @@ export interface IdentityToolDeps {
40
40
  /** Bind the same transaction port as every grant/status writer. */
41
41
  transactions: IdentityRepos["transactions"];
42
42
  tokens: AuthServiceDeps["tokens"];
43
+ usernameChanges?: AuthServiceDeps["usernameChanges"];
43
44
  }
44
45
  /**
45
46
  * This wiring layer's OWN risk classification, authored from what each handler below actually
@@ -48,6 +48,7 @@ function identityServiceDepsFrom(deps) {
48
48
  clock: deps.clock,
49
49
  idGen: deps.idGen,
50
50
  tokens: deps.tokens,
51
+ usernameChanges: deps.usernameChanges,
51
52
  };
52
53
  }
53
54
  /**
@@ -64,6 +65,8 @@ export const identityDerivedRisk = new Map([
64
65
  ["identity_user_create", "mutates-durable-state"],
65
66
  // -> updateUser (admin-crud-service.ts): users.save, email field only.
66
67
  ["identity_user_update_email", "mutates-durable-state"],
68
+ // -> updateUser (admin-crud-service.ts): users.save, username field only, plus the transactional audit.
69
+ ["identity_user_change_username", "mutates-durable-state"],
67
70
  // -> disablePrincipal (admin-crud-service.ts): status flip, guarded by the owner floor. See docs/decisions/DR-007-workspace-and-owner-floors.md.
68
71
  ["identity_user_disable", "mutates-durable-state"],
69
72
  // -> enablePrincipal (admin-crud-service.ts): status flip back to active.
@@ -283,6 +286,17 @@ export function buildIdentityRegistrations(routeDeps) {
283
286
  throw new Error(`principal '${input.req("principalId")}' was not found`);
284
287
  return { user: toIdentityUserView({ principal, user, roleIds: await roleIdsFor(routeDeps, principal.id) }) };
285
288
  },
289
+ identity_user_change_username: async (ctx) => {
290
+ const input = identityInput("identity_user_change_username", ctx.input);
291
+ const principalId = input.req("principalId");
292
+ const { user } = await updateUser({ deps: serviceDeps(), input: {
293
+ workspaceId: routeDeps.workspaceId, callerPrincipalId: ctx.principal.id, principalId,
294
+ } }, { username: input.req("username") });
295
+ const principal = await routeDeps.principalRepo.findById({ workspaceId: routeDeps.workspaceId, id: principalId });
296
+ if (!principal)
297
+ throw new Error(`principal '${principalId}' was not found`);
298
+ return { user: toIdentityUserView({ principal, user, roleIds: await roleIdsFor(routeDeps, principalId) }) };
299
+ },
286
300
  identity_user_disable: async (ctx) => {
287
301
  const input = identityInput("identity_user_disable", ctx.input);
288
302
  const { principal } = await disablePrincipal({
@@ -106,6 +106,17 @@ export const identityAgentToolCatalog = [
106
106
  },
107
107
  },
108
108
  },
109
+ {
110
+ name: "identity_user_change_username",
111
+ description: "Changes an operator user's login username. Call after finding their principalId with identity_user_list. Returns { user } with the normalized username; email, password and existing sessions stay unchanged. Requires user.manage even for your own profile; only owners may rename owner accounts. Fails for blank names, missing users, users in Trash, or names reserved by any user including Trash. Choose a different name or restore/purge the conflicting trashed user before retrying.",
112
+ sideEffects: "mutates-durable-state",
113
+ authorization: { permission: "user.manage" },
114
+ inputSchema: {
115
+ type: "object", additionalProperties: false, required: ["principalId", "username"],
116
+ properties: { principalId: PRINCIPAL_ID_SCHEMA, username: { type: "string", minLength: 1,
117
+ description: "New login name. Trimmed, lowercased and Unicode NFC normalized; unique per workspace, including users in Trash." } },
118
+ },
119
+ },
109
120
  {
110
121
  name: "identity_user_disable",
111
122
  description: "Disables a user, revoking their access. Principals are never hard-deleted because the audit trail references them, so this is how a user is removed. Refuses to disable the seeded owner, and refuses any disable that would leave the workspace with no active owner. Already-disabled is a no-op.",
@@ -1,5 +1,5 @@
1
1
  import { type UserManagementMessages } from "./messages.js";
2
- import type { SessionTokenPort } from "../core/runtime-ports.js";
2
+ import type { SessionTokenPort, UsernameChangePort } from "../core/runtime-ports.js";
3
3
  import type { Clock, IdGenerator, ISODateTime, UUID } from "@jini-ai/core/primitives";
4
4
  import type { IdentityRepos, PasswordHasherPort } from "../core/ports.js";
5
5
  import { type PrincipalRecord, type SessionRecord } from "../core/types.js";
@@ -9,6 +9,8 @@ export interface AuthServiceDeps {
9
9
  clock: Clock;
10
10
  idGen: IdGenerator;
11
11
  tokens: SessionTokenPort;
12
+ /** Additive: legacy email updates need no rename lifecycle; renames require it. */
13
+ usernameChanges?: UsernameChangePort | undefined;
12
14
  }
13
15
  /**
14
16
  * Mint and persist a session for a principal the caller has ALREADY identified by some means
@@ -24,6 +24,8 @@ export interface SeedIdentityInput {
24
24
  }
25
25
  export interface SeedIdentityOptions {
26
26
  ownerEmail?: string | undefined;
27
+ /** Stable bootstrap binding persisted by the host. Never derive this from a mutable login/title. */
28
+ seededOwnerPrincipalId?: UUID | undefined;
27
29
  }
28
30
  export interface SeedIdentityResult {
29
31
  ownerPrincipalId: UUID;
@@ -33,8 +35,8 @@ export interface SeedIdentityResult {
33
35
  * Seed first-boot identity data.
34
36
  *
35
37
  * Idempotent in two distinct senses, and both are load-bearing:
36
- * - A seed that RAN TO COMPLETION is a no-op — the owner-username lookup below early-returns after
37
- * a single confirming read (`ensureOwnerRoleBinding`) and writes nothing. (This path does not
38
+ * - A seed that RAN TO COMPLETION is a no-op — the owner lookup below early-returns after
39
+ * confirming its role binding (`ensureOwnerRoleBinding`) and writes nothing. (This path does not
38
40
  * reconcile a built-in policy whose permission list has since grown; that is the host's job — see
39
41
  * the host's `applyBuiltinRoleGrants` / `migrateDeprecatedPermissionGrants`, chained onto
40
42
  * `identityReady`.)
@@ -45,8 +47,9 @@ export interface SeedIdentityResult {
45
47
  * previous attempt already wrote and fills in only what is missing. See the block comment above
46
48
  * `ensureBuiltinRole` for why this function must survive being killed halfway.
47
49
  *
48
- * @complexity O(1) — fixed small number of inserts (4 roles/policies, ~2
49
- * dozen policy_permissions, 3 principals, 1 user, 1 role assignment).
50
+ * @complexity O(1) owner lookup plus the existing principal roster read; fresh seeds make a
51
+ * fixed small number of inserts (4 roles/policies, ~2 dozen policy_permissions,
52
+ * 3 principals, 1 user, 1 role assignment).
50
53
  * @overallScore 100
51
54
  */
52
55
  export declare function seedIdentity(required: {
@@ -1,5 +1,6 @@
1
1
  import { nowIso as kernelNowIso } from "@jini-ai/core/primitives";
2
2
  import { normalizeUsername } from "./username.js";
3
+ import { IdentityNotFoundError } from "../core/types.js";
3
4
  /**
4
5
  * @file First-boot identity seed (state.spec `SEED_FIRST_BOOT`).
5
6
  *
@@ -20,7 +21,7 @@ import { normalizeUsername } from "./username.js";
20
21
  * Seed data lives in one place so both composition roots stay identical
21
22
  * (mirrors `server/seed.ts`'s reasoning for workspace/post/presentation seed
22
23
  * data). Idempotent: re-seeding an already-seeded workspace is a no-op
23
- * (checked via the owner username).
24
+ * (checked via a host-supplied stable principal id, or the legacy owner username).
24
25
  * See docs/decisions/DR-001-identity-and-session-boundary.md.
25
26
  */
26
27
  /** : the literal id the legacy Article VI actor stamp resolves to. See docs/decisions/DR-001-identity-and-session-boundary.md. */
@@ -303,8 +304,8 @@ async function ensureOwnerRoleBinding(required) {
303
304
  * Seed first-boot identity data.
304
305
  *
305
306
  * Idempotent in two distinct senses, and both are load-bearing:
306
- * - A seed that RAN TO COMPLETION is a no-op — the owner-username lookup below early-returns after
307
- * a single confirming read (`ensureOwnerRoleBinding`) and writes nothing. (This path does not
307
+ * - A seed that RAN TO COMPLETION is a no-op — the owner lookup below early-returns after
308
+ * confirming its role binding (`ensureOwnerRoleBinding`) and writes nothing. (This path does not
308
309
  * reconcile a built-in policy whose permission list has since grown; that is the host's job — see
309
310
  * the host's `applyBuiltinRoleGrants` / `migrateDeprecatedPermissionGrants`, chained onto
310
311
  * `identityReady`.)
@@ -315,8 +316,9 @@ async function ensureOwnerRoleBinding(required) {
315
316
  * previous attempt already wrote and fills in only what is missing. See the block comment above
316
317
  * `ensureBuiltinRole` for why this function must survive being killed halfway.
317
318
  *
318
- * @complexity O(1) — fixed small number of inserts (4 roles/policies, ~2
319
- * dozen policy_permissions, 3 principals, 1 user, 1 role assignment).
319
+ * @complexity O(1) owner lookup plus the existing principal roster read; fresh seeds make a
320
+ * fixed small number of inserts (4 roles/policies, ~2 dozen policy_permissions,
321
+ * 3 principals, 1 user, 1 role assignment).
320
322
  * @overallScore 100
321
323
  */
322
324
  export async function seedIdentity(required, optional = {}) {
@@ -326,7 +328,14 @@ export async function seedIdentity(required, optional = {}) {
326
328
  const ownerUsername = normalizeUsername({ raw: input.ownerUsername });
327
329
  const ownerPassword = input.ownerPassword;
328
330
  const nowIso = kernelNowIso({ clock: deps.clock });
329
- const existingOwnerUser = await deps.repos.users.findByUsername({
331
+ // An explicit binding is authoritative, even when somebody has reused its former login name.
332
+ // A missing bound row is an error, never permission to mint a replacement owner.
333
+ const seededOwnerUser = input.seededOwnerPrincipalId !== undefined
334
+ ? await deps.repos.users.findByPrincipalId({ workspaceId, principalId: input.seededOwnerPrincipalId }) : null;
335
+ if (input.seededOwnerPrincipalId !== undefined && !seededOwnerUser) {
336
+ throw new IdentityNotFoundError({ message: "the bound seeded owner user was not found; restore the owner identity before booting" });
337
+ }
338
+ const existingOwnerUser = seededOwnerUser ?? await deps.repos.users.findByUsername({
330
339
  workspaceId,
331
340
  username: ownerUsername,
332
341
  });