@jini-ai/user-management 0.6.15-rc.2 → 0.6.16

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 (36) hide show
  1. package/README.md +2 -0
  2. package/dist/admin/adapters/http.js +15 -5
  3. package/dist/admin/adapters/memory.js +103 -38
  4. package/dist/admin/conformance/index.js +48 -30
  5. package/dist/admin/controllers/controller-store.js +18 -6
  6. package/dist/admin/controllers/members.controller.js +48 -21
  7. package/dist/admin/controllers/roles.controller.js +98 -49
  8. package/dist/admin/controllers/users.controller.js +22 -11
  9. package/dist/admin/error-copy.js +3 -1
  10. package/dist/admin/ports.d.ts +11 -10
  11. package/dist/admin/react/components/PermissionPanel.js +3 -2
  12. package/dist/admin/react/components/PolicyRow.js +3 -2
  13. package/dist/admin/react/components/RoleRow.js +3 -2
  14. package/dist/admin/react/components/UsersConfirmation.js +1 -2
  15. package/dist/admin/react/hooks/AuthScope.hooks.js +2 -1
  16. package/dist/admin/react/hooks/MembersPage.hooks.js +23 -10
  17. package/dist/admin/react/hooks/MembersScope.hooks.js +2 -1
  18. package/dist/admin/react/hooks/UsersPage.hooks.js +13 -5
  19. package/dist/admin/react/hooks/UsersScope.hooks.js +2 -1
  20. package/dist/admin/react/index.d.ts +6 -6
  21. package/dist/admin/rules.js +15 -7
  22. package/dist/core/ports.d.ts +6 -0
  23. package/dist/react/errors.js +3 -1
  24. package/dist/react/users/Users.js +2 -1
  25. package/dist/react/users/rules.js +9 -5
  26. package/dist/server/admin-crud-service.js +17 -13
  27. package/dist/server/agent-tool-registrations.js +18 -15
  28. package/dist/server/agent-tools.js +38 -31
  29. package/dist/server/auth-challenge.js +4 -3
  30. package/dist/server/hasher.js +2 -1
  31. package/dist/server/repo.memory-transactions.js +13 -9
  32. package/dist/server/repo.memory.d.ts +3 -0
  33. package/dist/server/repo.memory.js +8 -0
  34. package/dist/server/seed.d.ts +17 -1
  35. package/dist/server/seed.js +25 -2
  36. package/package.json +41 -32
@@ -1,4 +1,4 @@
1
- export declare const rolesKitNeeds: import("@jini-ai/ui-kit").KitNeeds<"Notice" | "Spinner" | "Tabs" | "Badge" | "Menu" | "Button" | "TextField" | "ConfirmDialog">;
1
+ export declare const rolesKitNeeds: import("@jini-ai/ui-kit").KitNeeds<"Notice" | "Spinner" | "Tabs" | "Menu" | "Badge" | "Button" | "TextField" | "ConfirmDialog">;
2
2
  /** Optional composition surface. Identity services remain owned by user-management. */
3
3
  export declare function roles(_required: Record<string, never>, _optional?: Record<string, never>): {
4
4
  id: "roles";
@@ -56,11 +56,11 @@ export declare function roles(_required: Record<string, never>, _optional?: Reco
56
56
  children: import("react").ReactNode;
57
57
  }, _optional?: Record<string, never>) => import("react").FunctionComponentElement<import("react").ProviderProps<import("./hooks/RolesScope.hooks.js").RolesScope | null>>;
58
58
  };
59
- kitNeeds: import("@jini-ai/ui-kit").KitNeeds<"Notice" | "Spinner" | "Tabs" | "Badge" | "Menu" | "Button" | "TextField" | "ConfirmDialog">;
59
+ kitNeeds: import("@jini-ai/ui-kit").KitNeeds<"Notice" | "Spinner" | "Tabs" | "Menu" | "Badge" | "Button" | "TextField" | "ConfirmDialog">;
60
60
  };
61
61
  export type { RolesPageProps, RolesTabProps } from './hooks/RolesBinding.hooks.js';
62
- export declare const usersKitNeeds: import("@jini-ai/ui-kit").KitNeeds<"Notice" | "Spinner" | "Badge" | "Menu" | "Button" | "TextField" | "Select" | "ConfirmDialog">;
63
- export declare const membersKitNeeds: import("@jini-ai/ui-kit").KitNeeds<"Notice" | "Spinner" | "Badge" | "Menu" | "Button" | "ConfirmDialog">;
62
+ export declare const usersKitNeeds: import("@jini-ai/ui-kit").KitNeeds<"Notice" | "Spinner" | "Menu" | "Badge" | "Button" | "TextField" | "Select" | "ConfirmDialog">;
63
+ export declare const membersKitNeeds: import("@jini-ai/ui-kit").KitNeeds<"Notice" | "Spinner" | "Menu" | "Badge" | "Button" | "ConfirmDialog">;
64
64
  export declare const authKitNeeds: import("@jini-ai/ui-kit").KitNeeds<"Notice" | "Spinner" | "Button" | "TextField">;
65
65
  export declare function users(_required: Record<string, never>, _optional?: Record<string, never>): {
66
66
  id: "users";
@@ -109,7 +109,7 @@ export declare function users(_required: Record<string, never>, _optional?: Reco
109
109
  children: import("react").ReactNode;
110
110
  }, _optional?: Record<string, never>) => import("react").FunctionComponentElement<import("react").ProviderProps<import("./hooks/UsersScope.hooks.js").UsersScope | null>>;
111
111
  };
112
- kitNeeds: import("@jini-ai/ui-kit").KitNeeds<"Notice" | "Spinner" | "Badge" | "Menu" | "Button" | "TextField" | "Select" | "ConfirmDialog">;
112
+ kitNeeds: import("@jini-ai/ui-kit").KitNeeds<"Notice" | "Spinner" | "Menu" | "Badge" | "Button" | "TextField" | "Select" | "ConfirmDialog">;
113
113
  };
114
114
  export declare function members(_required: Record<string, never>, _optional?: Record<string, never>): {
115
115
  id: "members";
@@ -154,7 +154,7 @@ export declare function members(_required: Record<string, never>, _optional?: Re
154
154
  children: import("react").ReactNode;
155
155
  }, _optional?: Record<string, never>) => import("react").FunctionComponentElement<import("react").ProviderProps<import("./hooks/MembersScope.hooks.js").MembersScope | null>>;
156
156
  };
157
- kitNeeds: import("@jini-ai/ui-kit").KitNeeds<"Notice" | "Spinner" | "Badge" | "Menu" | "Button" | "ConfirmDialog">;
157
+ kitNeeds: import("@jini-ai/ui-kit").KitNeeds<"Notice" | "Spinner" | "Menu" | "Badge" | "Button" | "ConfirmDialog">;
158
158
  };
159
159
  export declare function auth(_required: Record<string, never>, _optional?: Record<string, never>): {
160
160
  id: "auth";
@@ -22,13 +22,21 @@ export function describeRolesError({ error, fallback }, _optional = {}) {
22
22
  return errorMessageOr({ error, fallback });
23
23
  }
24
24
  export function destructiveCopy({ target }, _optional = {}) {
25
- if (target?.kind === 'permission')
26
- return { title: 'Remove permission?', label: 'Remove', tone: 'warning',
27
- body: `Remove "${target.row.permission}"${target.row.resourceType ? ` (${target.row.resourceType})` : ''} from this policy? This changes live access immediately.`, consequence: 'changes live access immediately' };
25
+ if (target?.kind === 'permission') {
26
+ const scope = target.row.resourceType ? ` (${target.row.resourceType})` : '';
27
+ return {
28
+ title: 'Remove permission?', label: 'Remove', tone: 'warning',
29
+ body: `Remove "${target.row.permission}"${scope} from this policy? This changes live access immediately.`, consequence: 'changes live access immediately'
30
+ };
31
+ }
28
32
  if (target?.kind === 'policy')
29
- return { title: 'Delete policy?', label: 'Delete', tone: 'danger',
30
- body: `Delete "${target.policy.name}"? Policies still attached to users or roles cannot be deleted.`, consequence: 'cannot be undone' };
31
- return { title: 'Delete role?', label: 'Delete', tone: 'danger',
32
- body: target?.kind === 'role' ? `Delete "${target.role.name}"? Roles still assigned to users cannot be deleted.` : '', consequence: 'cannot be undone' };
33
+ return {
34
+ title: 'Delete policy?', label: 'Delete', tone: 'danger',
35
+ body: `Delete "${target.policy.name}"? Policies still attached to users or roles cannot be deleted.`, consequence: 'cannot be undone'
36
+ };
37
+ return {
38
+ title: 'Delete role?', label: 'Delete', tone: 'danger',
39
+ body: target?.kind === 'role' ? `Delete "${target.role.name}"? Roles still assigned to users cannot be deleted.` : '', consequence: 'cannot be undone'
40
+ };
33
41
  }
34
42
  //# sourceMappingURL=rules.js.map
@@ -27,6 +27,12 @@ export interface PrincipalRepoPort {
27
27
  workspaceId: UUID;
28
28
  }): Promise<PrincipalRecord[]>;
29
29
  save(record: PrincipalRecord): Promise<void>;
30
+ /** Atomically insert by (workspaceId, id), or return the unchanged existing row.
31
+ * Durable adapters must enforce uniqueness in storage across connections/processes.
32
+ * Optional for existing hosts; ensurePrincipal fails closed without this capability. */
33
+ insertIfAbsent?(required: {
34
+ record: PrincipalRecord;
35
+ }, optional?: Record<string, never>): Promise<PrincipalRecord>;
30
36
  }
31
37
  export interface UserRepoPort {
32
38
  findByPrincipalId(required: {
@@ -5,7 +5,9 @@ function errorMessage(error) {
5
5
  }
6
6
  /** The string `code` an Error or plain object carries, or `undefined` when there is none. */
7
7
  function errorCode(error) {
8
- return error && typeof error === "object" && "code" in error && typeof error.code === "string" ? error.code : undefined;
8
+ if (!error || typeof error !== "object" || !("code" in error))
9
+ return undefined;
10
+ return typeof error.code === "string" ? error.code : undefined;
9
11
  }
10
12
  /** Error codes are data, so any host transport can supply them without a shared error class. */
11
13
  export function describeIdentityError({ error, fallback, translate }, { messages = {} } = {}) {
@@ -107,6 +107,7 @@ function resolveResetPasswordFieldsHook(override) {
107
107
  function UserResetPasswordDialog({ resetPasswordFor, setResetPasswordFor, newPassword, setNewPassword, passwordError, setPasswordError, passwordSaving, confirmResetPassword, t, useFields: useFieldsProp, }) {
108
108
  const useFields = resolveResetPasswordFieldsHook(useFieldsProp);
109
109
  const fields = useFields({ resetPasswordFor, newPassword });
110
+ const displayedError = fields.mismatch ? t("Passwords do not match.") : passwordError;
110
111
  /** Blocks the reset entirely when the two fields disagree — a mismatch never reaches
111
112
  * `confirmResetPassword`/the server. The inline message below is driven by the same `mismatch`
112
113
  * value, so it is already visible by the time a blocked click can happen; there is nothing
@@ -122,7 +123,7 @@ function UserResetPasswordDialog({ resetPasswordFor, setResetPasswordFor, newPas
122
123
  // `-confirm` sub-handle). Two elements answering to the same handle is a
123
124
  // page-authoring bug the page driver refuses to resolve (see
124
125
  // `dom-page-driver.ts`'s `ambiguousHandleError`), so this field gets its own name.
125
- agentBase: "users-reset-password-retype", t: t }), fields.mismatch ? (_jsx("p", { className: "save-error", role: "alert", children: t("Passwords do not match.") })) : passwordError ? (_jsx("p", { className: "save-error", role: "alert", children: passwordError })) : null] })) : null, confirmLabel: t("Reset password"), tone: "warning", pending: passwordSaving, onConfirm: handleConfirm, onCancel: () => {
126
+ agentBase: "users-reset-password-retype", t: t }), fields.mismatch || passwordError ? (_jsx("p", { className: "save-error", role: "alert", children: displayedError })) : null] })) : null, confirmLabel: t("Reset password"), tone: "warning", pending: passwordSaving, onConfirm: handleConfirm, onCancel: () => {
126
127
  setResetPasswordFor(null);
127
128
  setNewPassword("");
128
129
  setPasswordError(null);
@@ -60,8 +60,10 @@ export function userAccountCapabilities(required, _optional = {}) {
60
60
  }));
61
61
  const mayAct = known && (callerIsOwner || !protectedAccount);
62
62
  const destructive = mayAct && callerPrincipalId !== user.principalId;
63
- return { canDelete: destructive && canManageUserTrash, canDisable: destructive,
64
- canEnable: mayAct, canResetPassword: mayAct };
63
+ return {
64
+ canDelete: destructive && canManageUserTrash, canDisable: destructive,
65
+ canEnable: mayAct, canResetPassword: mayAct
66
+ };
65
67
  }
66
68
  export function isOwnerAccount(required, _optional = {}) {
67
69
  return required.user.isOwner ?? required.user.roleIds.some(id => {
@@ -138,9 +140,11 @@ export function userRowMenuItems({ user, toggleSaving, handlers, translate, canD
138
140
  onSelect: () => handlers.onRequestDelete(user),
139
141
  });
140
142
  }
141
- return items.filter(item => item.key === "toggle"
142
- ? (user.status === "active" ? permissions.canDisable : permissions.canEnable)
143
- : item.key !== "reset-password" || permissions.canResetPassword);
143
+ return items.filter(item => {
144
+ if (item.key === "toggle")
145
+ return user.status === "active" ? permissions.canDisable : permissions.canEnable;
146
+ return item.key !== "reset-password" || permissions.canResetPassword;
147
+ });
144
148
  }
145
149
  /**
146
150
  * The role or policy names a user holds, for the "Roles"/"Policies" table cells — `null` when the
@@ -6,6 +6,8 @@ import { normalizeUsername } from "./username.js";
6
6
  import { isKnownPermission } from "../core/permissions.js";
7
7
  import { principalKindMayExercisePermission } from "../core/principal-kind-policy.js";
8
8
  import { IdentityConflictError, IdentityNotFoundError, IdentityValidationError, OwnerRequiredError, PermissionUnknownError, } from "../core/types.js";
9
+ const USER_MANAGE = "user.manage";
10
+ const ROLE_MANAGE = "role.manage";
9
11
  /**
10
12
  * @file An amendment that completes the users/roles/policies admin CRUD surface
11
13
  * (feature.spec.md..19).
@@ -56,7 +58,7 @@ export async function disablePrincipal(required) {
56
58
  deps,
57
59
  workspaceId: input.workspaceId,
58
60
  callerPrincipalId: input.callerPrincipalId,
59
- permissions: ["user.manage"],
61
+ permissions: [USER_MANAGE],
60
62
  });
61
63
  const target = await deps.repos.principals.findById({ workspaceId: input.workspaceId, id: input.principalId });
62
64
  if (!target)
@@ -114,7 +116,7 @@ export async function enablePrincipal(required) {
114
116
  deps,
115
117
  workspaceId: input.workspaceId,
116
118
  callerPrincipalId: input.callerPrincipalId,
117
- permissions: ["user.manage"],
119
+ permissions: [USER_MANAGE],
118
120
  });
119
121
  const target = await deps.repos.principals.findById({ workspaceId: input.workspaceId, id: input.principalId });
120
122
  if (!target)
@@ -161,8 +163,8 @@ export async function updateUser(required, optional = {}) {
161
163
  callerPrincipalId: input.callerPrincipalId,
162
164
  // Preserve existing self-profile access without letting member.manage edit other operators.
163
165
  permissions: input.username === undefined && input.callerPrincipalId === input.principalId
164
- ? ["user.manage", "member.manage"]
165
- : ["user.manage"],
166
+ ? [USER_MANAGE, "member.manage"]
167
+ : [USER_MANAGE],
166
168
  });
167
169
  const target = await deps.repos.users.findByPrincipalId({
168
170
  workspaceId: input.workspaceId,
@@ -184,9 +186,11 @@ export async function updateUser(required, optional = {}) {
184
186
  await deps.repos.users.save(updated);
185
187
  if (username !== target.username) {
186
188
  // Renaming never changes the principal id or credentials: existing sessions stay valid.
187
- await deps.usernameChanges.record({ workspaceId: input.workspaceId, principalId: input.principalId,
189
+ await deps.usernameChanges.record({
190
+ workspaceId: input.workspaceId, principalId: input.principalId,
188
191
  callerPrincipalId: input.callerPrincipalId, previousUsername: target.username, username,
189
- occurredAt: kernelNowIso({ clock: deps.clock }) });
192
+ occurredAt: kernelNowIso({ clock: deps.clock })
193
+ });
190
194
  }
191
195
  return { user: updated };
192
196
  },
@@ -283,7 +287,7 @@ export async function resetUserPassword(required) {
283
287
  deps,
284
288
  workspaceId: input.workspaceId,
285
289
  callerPrincipalId: input.callerPrincipalId,
286
- permissions: ["user.manage"],
290
+ permissions: [USER_MANAGE],
287
291
  });
288
292
  const target = await deps.repos.users.findByPrincipalId({
289
293
  workspaceId: input.workspaceId,
@@ -344,7 +348,7 @@ export async function updateRole(required) {
344
348
  deps,
345
349
  workspaceId: input.workspaceId,
346
350
  callerPrincipalId: input.callerPrincipalId,
347
- permissions: ["role.manage"],
351
+ permissions: [ROLE_MANAGE],
348
352
  });
349
353
  const target = await deps.repos.roles.findById({ workspaceId: input.workspaceId, id: input.roleId });
350
354
  if (!target)
@@ -377,7 +381,7 @@ export async function updatePolicy(required, optional = {}) {
377
381
  deps,
378
382
  workspaceId: input.workspaceId,
379
383
  callerPrincipalId: input.callerPrincipalId,
380
- permissions: ["role.manage"],
384
+ permissions: [ROLE_MANAGE],
381
385
  });
382
386
  const target = await deps.repos.policies.findById({ workspaceId: input.workspaceId, id: input.policyId });
383
387
  if (!target)
@@ -424,7 +428,7 @@ export async function deleteRole(required) {
424
428
  deps,
425
429
  workspaceId: input.workspaceId,
426
430
  callerPrincipalId: input.callerPrincipalId,
427
- permissions: ["role.manage"],
431
+ permissions: [ROLE_MANAGE],
428
432
  });
429
433
  const target = await deps.repos.roles.findById({ workspaceId: input.workspaceId, id: input.roleId });
430
434
  if (!target)
@@ -468,7 +472,7 @@ export async function deletePolicy(required) {
468
472
  deps,
469
473
  workspaceId: input.workspaceId,
470
474
  callerPrincipalId: input.callerPrincipalId,
471
- permissions: ["role.manage"],
475
+ permissions: [ROLE_MANAGE],
472
476
  });
473
477
  const target = await deps.repos.policies.findById({ workspaceId: input.workspaceId, id: input.policyId });
474
478
  if (!target)
@@ -515,7 +519,7 @@ export async function writePolicyPermission(required, optional = {}) {
515
519
  deps,
516
520
  workspaceId: input.workspaceId,
517
521
  callerPrincipalId: input.callerPrincipalId,
518
- permissions: ["role.manage"],
522
+ permissions: [ROLE_MANAGE],
519
523
  });
520
524
  if (!isKnownPermission({ id: input.permission })) {
521
525
  throw new PermissionUnknownError({ message: `permission '${input.permission}' is not in the registered catalog` });
@@ -580,7 +584,7 @@ export async function removePolicyPermission(required) {
580
584
  deps,
581
585
  workspaceId: input.workspaceId,
582
586
  callerPrincipalId: input.callerPrincipalId,
583
- permissions: ["role.manage"],
587
+ permissions: [ROLE_MANAGE],
584
588
  });
585
589
  const policy = await deps.repos.policies.findById({ workspaceId: input.workspaceId, id: input.policyId });
586
590
  if (!policy)
@@ -16,6 +16,7 @@ import { parseIdentityToolInput } from "./agent-tool-input.js";
16
16
  import { identityAgentToolCatalog } from "./agent-tools.js";
17
17
  import { assertCallerHasAnyPermission, assignRole, attachPolicy, createPolicy, createRole, createUser } from "./grant-service.js";
18
18
  import { deletePolicy, deleteRole, disablePrincipal, enablePrincipal, updatePolicy, updateRole, updateUser } from "./admin-crud-service.js";
19
+ const MUTATES_DURABLE_STATE = "mutates-durable-state";
19
20
  const CATALOG_BY_ID = indexCatalogById({ catalog: identityAgentToolCatalog });
20
21
  /**
21
22
  * Assembles `auth-service.ts`'s `IdentityRepos` bag from {@link IdentityToolDeps}'s flat fields — a
@@ -62,34 +63,34 @@ export const identityDerivedRisk = new Map([
62
63
  ["identity_role_list", "none"],
63
64
  ["identity_policy_list", "none"],
64
65
  // -> createUser (grant-service.ts): saves a principal row AND a users row (credential).
65
- ["identity_user_create", "mutates-durable-state"],
66
+ ["identity_user_create", MUTATES_DURABLE_STATE],
66
67
  // -> updateUser (admin-crud-service.ts): users.save, email field only.
67
- ["identity_user_update_email", "mutates-durable-state"],
68
+ ["identity_user_update_email", MUTATES_DURABLE_STATE],
68
69
  // -> updateUser (admin-crud-service.ts): users.save, username field only, plus the transactional audit.
69
- ["identity_user_change_username", "mutates-durable-state"],
70
+ ["identity_user_change_username", MUTATES_DURABLE_STATE],
70
71
  // -> disablePrincipal (admin-crud-service.ts): status flip, guarded by the owner floor. See docs/decisions/DR-007-workspace-and-owner-floors.md.
71
- ["identity_user_disable", "mutates-durable-state"],
72
+ ["identity_user_disable", MUTATES_DURABLE_STATE],
72
73
  // -> enablePrincipal (admin-crud-service.ts): status flip back to active.
73
- ["identity_user_enable", "mutates-durable-state"],
74
+ ["identity_user_enable", MUTATES_DURABLE_STATE],
74
75
  // -> createRole (grant-service.ts): roles.save, always isBuiltin=false.
75
- ["identity_role_create", "mutates-durable-state"],
76
+ ["identity_role_create", MUTATES_DURABLE_STATE],
76
77
  // -> assignRole (grant-service.ts): principalRoles.save behind the grant clamp. The one. See docs/decisions/DR-007-workspace-and-owner-floors.md.
77
78
  // identity tool that confers permissions, hence the heaviest of these.
78
- ["identity_role_assign", "mutates-durable-state"],
79
+ ["identity_role_assign", MUTATES_DURABLE_STATE],
79
80
  // -> updateRole (admin-crud-service.ts): roles.save, refuses built-ins.
80
- ["identity_role_rename", "mutates-durable-state"],
81
+ ["identity_role_rename", MUTATES_DURABLE_STATE],
81
82
  // -> deleteRole (admin-crud-service.ts): roles.delete, refuses built-ins and referenced roles.
82
- ["identity_role_delete", "mutates-durable-state"],
83
+ ["identity_role_delete", MUTATES_DURABLE_STATE],
83
84
  // -> createPolicy (grant-service.ts): policies.save, always isBuiltin=false/isFrozen=false.
84
- ["identity_policy_create", "mutates-durable-state"],
85
+ ["identity_policy_create", MUTATES_DURABLE_STATE],
85
86
  // -> updatePolicy (admin-crud-service.ts): policies.save, refuses built-in/frozen.
86
- ["identity_policy_update", "mutates-durable-state"],
87
+ ["identity_policy_update", MUTATES_DURABLE_STATE],
87
88
  // -> deletePolicy (admin-crud-service.ts): policyPermissions.deleteByPolicyId + policies.delete,
88
89
  // refuses built-in/frozen and still-referenced policies.
89
- ["identity_policy_delete", "mutates-durable-state"],
90
+ ["identity_policy_delete", MUTATES_DURABLE_STATE],
90
91
  // -> attachPolicy (grant-service.ts): principalPolicies.save behind the grant clamp — the. See docs/decisions/DR-007-workspace-and-owner-floors.md.
91
92
  // direct-grant counterpart to identity_role_assign, same weight.
92
- ["identity_policy_attach", "mutates-durable-state"],
93
+ ["identity_policy_attach", MUTATES_DURABLE_STATE],
93
94
  ]);
94
95
  /**
95
96
  * Upper bound on the roster `identity_user_list` will fan out over.
@@ -297,9 +298,11 @@ export function buildIdentityRegistrations(routeDeps) {
297
298
  identity_user_change_username: async (ctx) => {
298
299
  const input = identityInput({ toolId: "identity_user_change_username", input: ctx.input });
299
300
  const principalId = input.req("principalId");
300
- const { user } = await updateUser({ deps: serviceDeps(), input: {
301
+ const { user } = await updateUser({
302
+ deps: serviceDeps(), input: {
301
303
  workspaceId: routeDeps.workspaceId, callerPrincipalId: ctx.principal.id, principalId,
302
- } }, { username: input.req("username") });
304
+ }
305
+ }, { username: input.req("username") });
303
306
  const principal = await routeDeps.principalRepo.findById({ workspaceId: routeDeps.workspaceId, id: principalId });
304
307
  if (!principal)
305
308
  throw new Error(`principal '${principalId}' was not found`);
@@ -1,3 +1,6 @@
1
+ const USER_MANAGE = "user.manage";
2
+ const ROLE_MANAGE = "role.manage";
3
+ const MUTATES_DURABLE_STATE = "mutates-durable-state";
1
4
  /** A workspace-scoped principal id, as returned by `identity_user_list` or `identity_user_create`. */
2
5
  const PRINCIPAL_ID_SCHEMA = {
3
6
  type: "string",
@@ -50,28 +53,28 @@ export const identityAgentToolCatalog = [
50
53
  name: "identity_user_list",
51
54
  description: "Lists the workspace's human operator users with their principalId, username, status, and assigned role ids. Read-only. Call this to find a user's principalId before updating, disabling, enabling, or assigning a role to them. There is no password-reset tool. The owner resets a user's password from the Users screen in the admin (Reset password).",
52
55
  sideEffects: "none",
53
- authorization: { permission: "user.manage", orPermission: "member.manage" },
56
+ authorization: { permission: USER_MANAGE, orPermission: "member.manage" },
54
57
  inputSchema: NO_INPUT_SCHEMA,
55
58
  },
56
59
  {
57
60
  name: "identity_role_list",
58
61
  description: "Lists the workspace's roles (the four built-ins plus any custom ones) with their id, name, and isBuiltin flag. Read-only. Call this to find a roleId before assigning, renaming, or deleting a role.",
59
62
  sideEffects: "none",
60
- authorization: { permission: "role.manage" },
63
+ authorization: { permission: ROLE_MANAGE },
61
64
  inputSchema: NO_INPUT_SCHEMA,
62
65
  },
63
66
  {
64
67
  name: "identity_policy_list",
65
68
  description: "Lists the workspace's policies (built-ins plus any custom ones) with their id, name, description, isBuiltin, and isFrozen flags. Read-only. Call this to find a policyId before renaming, deleting, or attaching a policy.",
66
69
  sideEffects: "none",
67
- authorization: { permission: "role.manage" },
70
+ authorization: { permission: ROLE_MANAGE },
68
71
  inputSchema: NO_INPUT_SCHEMA,
69
72
  },
70
73
  {
71
74
  name: "identity_user_create",
72
75
  description: "Creates a new human operator user, minting a new principal and its credential row together. The new user starts with NO roles and therefore no permissions — grant access afterwards with identity_role_assign. Fails if the username is already taken.",
73
- sideEffects: "mutates-durable-state",
74
- authorization: { permission: "user.manage" },
76
+ sideEffects: MUTATES_DURABLE_STATE,
77
+ authorization: { permission: USER_MANAGE },
75
78
  inputSchema: {
76
79
  type: "object",
77
80
  additionalProperties: false,
@@ -94,8 +97,8 @@ export const identityAgentToolCatalog = [
94
97
  {
95
98
  name: "identity_user_update_email",
96
99
  description: "Sets or clears an existing user's email address. Requires user.manage for other operator users; member.manage permits only editing your own email. Only an owner may edit an owner account. Omitting 'email' (or passing an empty string) CLEARS the stored value rather than leaving it unchanged. Username and password are not editable through this tool. There is no password-reset tool. The owner resets a user's password from the Users screen in the admin (Reset password).",
97
- sideEffects: "mutates-durable-state",
98
- authorization: { permission: "user.manage", orPermission: "member.manage" },
100
+ sideEffects: MUTATES_DURABLE_STATE,
101
+ authorization: { permission: USER_MANAGE, orPermission: "member.manage" },
99
102
  inputSchema: {
100
103
  type: "object",
101
104
  additionalProperties: false,
@@ -109,33 +112,37 @@ export const identityAgentToolCatalog = [
109
112
  {
110
113
  name: "identity_user_change_username",
111
114
  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" },
115
+ sideEffects: MUTATES_DURABLE_STATE,
116
+ authorization: { permission: USER_MANAGE },
114
117
  inputSchema: {
115
118
  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." } },
119
+ properties: {
120
+ principalId: PRINCIPAL_ID_SCHEMA, username: {
121
+ type: "string", minLength: 1,
122
+ description: "New login name. Trimmed, lowercased and Unicode NFC normalized; unique per workspace, including users in Trash."
123
+ }
124
+ },
118
125
  },
119
126
  },
120
127
  {
121
128
  name: "identity_user_disable",
122
129
  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.",
123
- sideEffects: "mutates-durable-state",
124
- authorization: { permission: "user.manage" },
130
+ sideEffects: MUTATES_DURABLE_STATE,
131
+ authorization: { permission: USER_MANAGE },
125
132
  inputSchema: PRINCIPAL_ONLY_INPUT_SCHEMA,
126
133
  },
127
134
  {
128
135
  name: "identity_user_enable",
129
136
  description: "Re-enables a previously disabled human user. Already-active is a no-op. Only kind='user' principals can be enabled this way.",
130
- sideEffects: "mutates-durable-state",
131
- authorization: { permission: "user.manage" },
137
+ sideEffects: MUTATES_DURABLE_STATE,
138
+ authorization: { permission: USER_MANAGE },
132
139
  inputSchema: PRINCIPAL_ONLY_INPUT_SCHEMA,
133
140
  },
134
141
  {
135
142
  name: "identity_role_create",
136
143
  description: "Creates a new custom role. The role starts with no policies attached and therefore confers no permissions until one is attached, which is not an agent-callable operation.",
137
- sideEffects: "mutates-durable-state",
138
- authorization: { permission: "role.manage" },
144
+ sideEffects: MUTATES_DURABLE_STATE,
145
+ authorization: { permission: ROLE_MANAGE },
139
146
  inputSchema: {
140
147
  type: "object",
141
148
  additionalProperties: false,
@@ -146,8 +153,8 @@ export const identityAgentToolCatalog = [
146
153
  {
147
154
  name: "identity_role_assign",
148
155
  description: "Assigns an existing role to an existing user. This is the step that actually grants permissions. The caller can only confer permissions it already holds itself unconstrained, so assigning a role more powerful than the caller's own is refused. There is no tool to remove a role from a user, and no unassign operation exists anywhere in this system yet; tell the owner rather than trying another tool.",
149
- sideEffects: "mutates-durable-state",
150
- authorization: { permission: "role.manage" },
156
+ sideEffects: MUTATES_DURABLE_STATE,
157
+ authorization: { permission: ROLE_MANAGE },
151
158
  inputSchema: {
152
159
  type: "object",
153
160
  additionalProperties: false,
@@ -158,8 +165,8 @@ export const identityAgentToolCatalog = [
158
165
  {
159
166
  name: "identity_role_rename",
160
167
  description: "Renames a custom role. Built-in roles cannot be renamed — relabelling 'viewer' to read as trusted is a social-engineering variant of privilege escalation, so it is refused.",
161
- sideEffects: "mutates-durable-state",
162
- authorization: { permission: "role.manage" },
168
+ sideEffects: MUTATES_DURABLE_STATE,
169
+ authorization: { permission: ROLE_MANAGE },
163
170
  inputSchema: {
164
171
  type: "object",
165
172
  additionalProperties: false,
@@ -170,8 +177,8 @@ export const identityAgentToolCatalog = [
170
177
  {
171
178
  name: "identity_role_delete",
172
179
  description: "Deletes a custom role. Built-in roles cannot be deleted. A role still assigned to at least one user is refused — this system has no unassign operation, so a role in use cannot be removed at all.",
173
- sideEffects: "mutates-durable-state",
174
- authorization: { permission: "role.manage" },
180
+ sideEffects: MUTATES_DURABLE_STATE,
181
+ authorization: { permission: ROLE_MANAGE },
175
182
  inputSchema: {
176
183
  type: "object",
177
184
  additionalProperties: false,
@@ -182,8 +189,8 @@ export const identityAgentToolCatalog = [
182
189
  {
183
190
  name: "identity_policy_create",
184
191
  description: "Creates a new custom policy. The policy starts with no permissions and therefore confers none until permissions are added, which is not an agent-callable operation — identity_policy_write_permission is deliberately not wired (see this file's own header comment). Attaching a freshly created, still-empty policy to anyone is a no-op.",
185
- sideEffects: "mutates-durable-state",
186
- authorization: { permission: "role.manage" },
192
+ sideEffects: MUTATES_DURABLE_STATE,
193
+ authorization: { permission: ROLE_MANAGE },
187
194
  inputSchema: {
188
195
  type: "object",
189
196
  additionalProperties: false,
@@ -197,8 +204,8 @@ export const identityAgentToolCatalog = [
197
204
  {
198
205
  name: "identity_policy_update",
199
206
  description: "Renames and/or re-describes a custom policy. Built-in and frozen policies are refused. At least one of 'name'/'description' must be supplied.",
200
- sideEffects: "mutates-durable-state",
201
- authorization: { permission: "role.manage" },
207
+ sideEffects: MUTATES_DURABLE_STATE,
208
+ authorization: { permission: ROLE_MANAGE },
202
209
  inputSchema: {
203
210
  type: "object",
204
211
  additionalProperties: false,
@@ -213,8 +220,8 @@ export const identityAgentToolCatalog = [
213
220
  {
214
221
  name: "identity_policy_delete",
215
222
  description: "Deletes a custom policy. Built-in and frozen policies cannot be deleted. A policy still referenced by at least one role or attached to at least one user is refused — deletion fails safe (it only ever removes access, never orphans a reference) exactly like identity_role_delete.",
216
- sideEffects: "mutates-durable-state",
217
- authorization: { permission: "role.manage" },
223
+ sideEffects: MUTATES_DURABLE_STATE,
224
+ authorization: { permission: ROLE_MANAGE },
218
225
  inputSchema: {
219
226
  type: "object",
220
227
  additionalProperties: false,
@@ -227,8 +234,8 @@ export const identityAgentToolCatalog = [
227
234
  description:
228
235
  // 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.
229
236
  "Attaches an existing policy directly to an existing user, granting every permission the policy carries. This is the direct-grant counterpart to identity_role_assign, targets exactly one named user per call, and carries the identical grant-authority clamp: the caller can only confer permissions it already holds itself unconstrained, so attaching a policy more powerful than the caller's own is refused. There is no detach operation.",
230
- sideEffects: "mutates-durable-state",
231
- authorization: { permission: "role.manage" },
237
+ sideEffects: MUTATES_DURABLE_STATE,
238
+ authorization: { permission: ROLE_MANAGE },
232
239
  inputSchema: {
233
240
  type: "object",
234
241
  additionalProperties: false,
@@ -14,7 +14,7 @@ export function newChallengeSecrets({ tokens, randomInt }, _options = {}) {
14
14
  }
15
15
  /** Exact ASCII shape; without the m flag, $ also refuses trailing line terminators. O(1). */
16
16
  function isChallengeCode(code) {
17
- return /^[0-9]{6}$/.test(code);
17
+ return /^\d{6}$/.test(code);
18
18
  }
19
19
  /**
20
20
  * Hash the low-entropy code using the existing password hasher, never a fast digest.
@@ -38,8 +38,9 @@ export async function verifyChallengeCode({ code, hash, hasher }, _options = {})
38
38
  }
39
39
  /** Invalid counters or limits deny access; O(1), without changing the stored counter. */
40
40
  function hasAttemptsRemaining(failedAttempts, maxAttempts) {
41
- return Number.isSafeInteger(failedAttempts) && failedAttempts >= 0
42
- && Number.isSafeInteger(maxAttempts) && maxAttempts > 0 && failedAttempts < maxAttempts;
41
+ if (!Number.isSafeInteger(failedAttempts) || failedAttempts < 0)
42
+ return false;
43
+ return Number.isSafeInteger(maxAttempts) && maxAttempts > 0 && failedAttempts < maxAttempts;
43
44
  }
44
45
  /**
45
46
  * Decide metadata eligibility only; callers must also verify the token/code and use the
@@ -1,8 +1,9 @@
1
+ import { loadOptionalPeer } from '@jini-ai/core/optional-peer';
1
2
  import { createRequire } from "node:module";
2
3
  const requirePeer = createRequire(import.meta.url);
3
4
  /** Resolve the optional native peer only when this adapter is used. */
4
5
  export function loadArgon2Binding(_required) {
5
- const native = requirePeer("argon2");
6
+ const native = loadOptionalPeer({ feature: 'Argon2 password hashing', dependency: 'argon2', load: () => requirePeer("argon2") });
6
7
  return {
7
8
  argon2id: native.argon2id,
8
9
  hash: ({ password }, optional = {}) => native.hash(password, { ...DEFAULT_OPTIONS, type: native.argon2id, ...optional }),
@@ -35,13 +35,14 @@ export function createTransactionalInMemoryIdentityRepos(required) {
35
35
  release();
36
36
  }
37
37
  }
38
+ const snapshot = (repo) => {
39
+ const rows = repo.snapshotRows({});
40
+ return { restore: () => repo.restoreRows({ rows }) };
41
+ };
38
42
  const transactions = {
39
43
  run: ({ execute }) => exclusive(async () => {
40
44
  // Every store was checked to be InMemoryIdentityRows when the bag was bound above.
41
- const snapshots = stores.map((repo) => {
42
- const rows = repo.snapshotRows({});
43
- return { restore: () => repo.restoreRows({ rows }) };
44
- });
45
+ const snapshots = stores.map(snapshot);
45
46
  try {
46
47
  return await execute();
47
48
  }
@@ -52,17 +53,20 @@ export function createTransactionalInMemoryIdentityRepos(required) {
52
53
  }
53
54
  }),
54
55
  };
56
+ function queuedMember(member, target) {
57
+ return (...args) => exclusive(async () => {
58
+ // Detach inputs/outputs so a row reference outside the queue cannot mutate stored state.
59
+ const result = await Reflect.apply(member, target, structuredClone(args));
60
+ return structuredClone(result);
61
+ });
62
+ }
55
63
  function wrap(repo) {
56
64
  return new Proxy(repo, {
57
65
  get(target, property, receiver) {
58
66
  const member = Reflect.get(target, property, receiver);
59
67
  if (typeof member !== 'function')
60
68
  return member;
61
- return (...args) => exclusive(async () => {
62
- // Detach inputs/outputs so a row reference outside the queue cannot mutate stored state.
63
- const result = await Reflect.apply(member, target, structuredClone(args));
64
- return structuredClone(result);
65
- });
69
+ return queuedMember(member, target);
66
70
  },
67
71
  });
68
72
  }
@@ -40,6 +40,9 @@ export declare class InMemoryPrincipalRepo extends InMemoryIdentityRows<Principa
40
40
  workspaceId: string;
41
41
  }): Promise<PrincipalRecord[]>;
42
42
  save(record: PrincipalRecord): Promise<void>;
43
+ insertIfAbsent({ record }: {
44
+ record: PrincipalRecord;
45
+ }, _optional?: Record<string, never>): Promise<PrincipalRecord>;
43
46
  }
44
47
  export declare class InMemoryUserRepo extends InMemoryIdentityRows<UserRecord> implements UserRepoPort {
45
48
  constructor(_required: Record<string, never>, { initialRows }?: {
@@ -42,6 +42,14 @@ export class InMemoryPrincipalRepo extends InMemoryIdentityRows {
42
42
  async save(record) {
43
43
  upsertRecord({ records: this.rows, record, identity: { workspaceId: record.workspaceId, id: record.id } });
44
44
  }
45
+ async insertIfAbsent({ record }, _optional = {}) {
46
+ // No await between lookup and insert: competing calls on this store cannot interleave.
47
+ const existing = this.rows.find(row => matchesRecordFields({ record: row, fields: { workspaceId: record.workspaceId, id: record.id } }));
48
+ if (existing)
49
+ return existing;
50
+ this.rows.push(record);
51
+ return record;
52
+ }
45
53
  }
46
54
  export class InMemoryUserRepo extends InMemoryIdentityRows {
47
55
  constructor(_required, { initialRows = [] } = {}) {