@jini-ai/user-management 0.2.0 → 0.3.1

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 (41) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/dist/admin/people.rules.js +2 -1
  3. package/dist/core/index.d.ts +1 -0
  4. package/dist/core/index.js +1 -0
  5. package/dist/core/principal-kind-policy.d.ts +30 -0
  6. package/dist/core/principal-kind-policy.js +28 -0
  7. package/dist/core/types.d.ts +7 -2
  8. package/dist/react/index.d.ts +4 -0
  9. package/dist/react/index.js +2 -0
  10. package/dist/react/members/Members.d.ts +28 -1
  11. package/dist/react/members/Members.js +14 -10
  12. package/dist/react/members/hooks/use-members.hooks.d.ts +43 -1
  13. package/dist/react/members/hooks/use-members.hooks.js +37 -9
  14. package/dist/react/members/rules.d.ts +31 -4
  15. package/dist/react/members/rules.js +17 -5
  16. package/dist/react/models.d.ts +3 -0
  17. package/dist/react/users/Users.d.ts +105 -6
  18. package/dist/react/users/Users.js +105 -43
  19. package/dist/react/users/hooks/use-reset-password-fields.hooks.d.ts +33 -1
  20. package/dist/react/users/hooks/use-reset-password-fields.hooks.js +16 -1
  21. package/dist/react/users/hooks/use-users.hooks.d.ts +120 -3
  22. package/dist/react/users/hooks/use-users.hooks.js +192 -23
  23. package/dist/react/users/rules.d.ts +115 -8
  24. package/dist/react/users/rules.js +109 -6
  25. package/dist/server/admin-crud-service.js +4 -1
  26. package/dist/server/authorize.d.ts +9 -6
  27. package/dist/server/authorize.js +11 -3
  28. package/dist/server/permission-migrations.d.ts +35 -15
  29. package/dist/server/permission-migrations.js +16 -16
  30. package/dist/server/seed.js +6 -1
  31. package/package.json +15 -10
  32. package/dist/react/roles/Roles.d.ts +0 -100
  33. package/dist/react/roles/Roles.hooks.d.ts +0 -17
  34. package/dist/react/roles/Roles.hooks.js +0 -40
  35. package/dist/react/roles/Roles.js +0 -150
  36. package/dist/react/roles/hooks/use-roles.hooks.d.ts +0 -80
  37. package/dist/react/roles/hooks/use-roles.hooks.js +0 -303
  38. package/dist/react/roles/rules.d.ts +0 -35
  39. package/dist/react/roles/rules.js +0 -31
  40. package/dist/react/roles/text.d.ts +0 -27
  41. package/dist/react/roles/text.js +0 -13
@@ -1,33 +1,140 @@
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
+ /** Delete-user plan v2 (2026-09-24) — always opens the confirm dialog, never fires immediately:
51
+ * unlike Enable, "Delete" moves the user to the Trash, a destructive-looking action even though
52
+ * it is recoverable there for 60 days. Only asked for when `userRowMenuItems`'s own `canDelete`
53
+ * is true — see that function's doc comment. */
18
54
  onRequestDelete: (user: AdminIdentityUser) => void;
19
55
  }
20
- export declare function userRowMenuItems({ user, toggleSaving, handlers, translate, canDelete }: {
56
+ export interface UserAccountCapabilities {
57
+ canDelete: boolean;
58
+ canDisable: boolean;
59
+ canEnable: boolean;
60
+ canResetPassword: boolean;
61
+ }
62
+ /** Courtesy mirror of the server target rule. Unknown identity/roles fail closed. */
63
+ export declare function userAccountCapabilities(required: {
64
+ user: AdminIdentityUser;
65
+ callerPrincipalId: string | null;
66
+ callerIsOwner: boolean;
67
+ canManageUserTrash: boolean;
68
+ roles: ReadonlyMap<string, {
69
+ name: string;
70
+ isBuiltin: boolean;
71
+ }>;
72
+ }, _optional?: Record<string, never>): UserAccountCapabilities;
73
+ export declare function isOwnerAccount(required: {
74
+ user: AdminIdentityUser;
75
+ roles: ReadonlyMap<string, {
76
+ name: string;
77
+ isBuiltin: boolean;
78
+ }>;
79
+ }, _optional?: Record<string, never>): boolean;
80
+ /**
81
+ * `RowMenu` items for one user row — matches `Posts.tsx`/`Pages.tsx`'s three-dot menu shape:
82
+ * Disable/Enable, Manage, Reset password, and — delete-user plan v2 (2026-09-24) — Delete, appended
83
+ * only when `canDelete` is true. The server gate (owner or the built-in `admin` role,
84
+ * `delete-user-service.ts`'s `callerMayManageUserTrash`) is the real boundary; `canDelete` is this
85
+ * row's own affordance-hiding mirror of it, computed once per render from `/auth/me` by
86
+ * `use-users.hooks.ts` — see that hook's `canManageUserTrash` state for where it comes from.
87
+ *
88
+ * Disable/Enable share a single "toggle" item (label follows status, same shape as
89
+ * `Redirects.tsx`'s own toggle item) — Disable confirms via the modal below; Enable fires
90
+ * immediately, matching this screen's existing behavior (Enable was never confirm-gated). The
91
+ * `toggleSaving` guard reproduces the original inline check (`if (toggleSavingId) return`) against
92
+ * whatever row action is currently in flight, read fresh at click time.
93
+ *
94
+ * "Manage" always reads "Manage", never "Close": the item still toggles the expanded panel
95
+ * (`onManage` — closing it again by selecting "Manage" a second time still works exactly as it did
96
+ * as a standalone button), but a `RowMenu` item disappears the instant it is selected, so a label
97
+ * that flips to "Close" is never actually visible mid-interaction — it would only ever describe a
98
+ * state the operator cannot see while the menu that shows it is open. A static label sidesteps that
99
+ * without losing any capability.
100
+ *
101
+ * "Delete" always opens the confirm dialog (`onRequestDelete`), matching Disable's shape rather than
102
+ * Enable's immediate-fire one — see `UserRowMenuHandlers.onRequestDelete`'s own doc comment.
103
+ *
104
+ * @complexity Time/space: O(1) — at most four entries, no iteration.
105
+ */
106
+ export declare function userRowMenuItems({ user, toggleSaving, handlers, translate, canDelete, capabilities }: {
21
107
  user: AdminIdentityUser;
22
108
  toggleSaving: boolean;
23
109
  handlers: UserRowMenuHandlers;
24
110
  translate: Translate;
25
111
  canDelete: boolean;
26
- }): RowMenuItem[];
27
- export declare function formatGrantLabel({ ids, byId }: {
112
+ capabilities?: boolean | UserAccountCapabilities | undefined;
113
+ }, _optional?: Record<string, never>): RowMenuItem[];
114
+ /**
115
+ * The role or policy names a user holds, for the "Roles"/"Policies" table cells — `null` when the
116
+ * user holds none, which the caller renders as the `muted-cell` "none" fallback (a plain ternary on
117
+ * this already-computed value, per this feature's extraction rule).
118
+ *
119
+ * A grant id with no matching entry in `byId` (a role/policy deleted out from under a still-held
120
+ * grant) falls back to rendering the raw id rather than dropping it silently — the operator sees
121
+ * something is wrong instead of an undercount with no explanation.
122
+ *
123
+ * @complexity Time: O(n) in the number of granted ids; space: O(n) for the joined string.
124
+ */
125
+ export declare function grantOptionLabel(required: {
126
+ option: {
127
+ name: string;
128
+ isBuiltin: boolean;
129
+ };
130
+ translate: (key: string) => string;
131
+ }, _optional?: Record<string, never>): string;
132
+ export declare function formatGrantLabel({ ids, byId, translate }: {
28
133
  ids: readonly string[];
29
134
  byId: ReadonlyMap<string, {
30
135
  name: string;
136
+ isBuiltin?: boolean;
31
137
  }>;
32
- }): string | null;
138
+ translate?: Translate | undefined;
139
+ }, _optional?: Record<string, never>): string | null;
33
140
  //# 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,10 @@ 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 (permissions.canDelete) {
47
131
  items.push({
48
132
  key: "delete",
49
133
  label: translate("Delete"),
@@ -51,11 +135,30 @@ export function userRowMenuItems({ user, toggleSaving, handlers, translate, canD
51
135
  onSelect: () => handlers.onRequestDelete(user),
52
136
  });
53
137
  }
54
- return items;
138
+ return items.filter(item => item.key === "toggle"
139
+ ? (user.status === "active" ? permissions.canDisable : permissions.canEnable)
140
+ : item.key !== "reset-password" || permissions.canResetPassword);
141
+ }
142
+ /**
143
+ * The role or policy names a user holds, for the "Roles"/"Policies" table cells — `null` when the
144
+ * user holds none, which the caller renders as the `muted-cell` "none" fallback (a plain ternary on
145
+ * this already-computed value, per this feature's extraction rule).
146
+ *
147
+ * A grant id with no matching entry in `byId` (a role/policy deleted out from under a still-held
148
+ * grant) falls back to rendering the raw id rather than dropping it silently — the operator sees
149
+ * something is wrong instead of an undercount with no explanation.
150
+ *
151
+ * @complexity Time: O(n) in the number of granted ids; space: O(n) for the joined string.
152
+ */
153
+ export function grantOptionLabel(required, _optional = {}) {
154
+ return required.option.isBuiltin && required.option.name === "owner" ? required.translate("Owner") : required.option.name;
55
155
  }
56
- export function formatGrantLabel({ ids, byId }) {
156
+ export function formatGrantLabel({ ids, byId, translate = (key) => key }, _optional = {}) {
57
157
  if (ids.length === 0)
58
158
  return null;
59
- return ids.map((id) => byId.get(id)?.name ?? id).join(", ");
159
+ return ids.map(id => {
160
+ const role = byId.get(id);
161
+ return role?.isBuiltin && role.name === "owner" ? translate("Owner") : role?.name ?? id;
162
+ }).join(", ");
60
163
  }
61
164
  //# sourceMappingURL=rules.js.map
@@ -3,6 +3,7 @@ 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
5
  import { isKnownPermission } from "../core/permissions.js";
6
+ import { principalKindMayExercisePermission } from "../core/principal-kind-policy.js";
6
7
  import { IdentityConflictError, IdentityNotFoundError, IdentityValidationError, OwnerRequiredError, PermissionUnknownError, } from "../core/types.js";
7
8
  /**
8
9
  * @file An amendment that completes the users/roles/policies admin CRUD surface
@@ -554,7 +555,9 @@ export async function removePolicyPermission(required) {
554
555
  async function countActiveOwnerWildcardPrincipals(required) {
555
556
  const { deps, workspaceId } = required;
556
557
  const principals = await deps.repos.principals.list({ workspaceId });
557
- const activePrincipals = principals.filter((principal) => principal.status === "active");
558
+ // A kind barred from operator permissions (a site `member`) can never act as an owner, so it is
559
+ // not counted even if a `*` row reaches it — and members never cost a grant read here.
560
+ const activePrincipals = principals.filter((principal) => principal.status === "active" && principalKindMayExercisePermission({ kind: principal.kind, permission: "*" }));
558
561
  const flags = await Promise.all(activePrincipals.map((principal) => principalHoldsOwnerWildcard({ deps, workspaceId, principalId: principal.id })));
559
562
  return flags.filter(Boolean).length;
560
563
  }
@@ -19,8 +19,8 @@ import type { PolicyPermissionRecord } from "../core/types.js";
19
19
  *
20
20
  * Architectural role:
21
21
  * Implements the matcher precedence in behavior.spec §1.1 exactly:
22
- * disabled-principal short-circuit > owner wildcard > exact matching row >
23
- * fail-closed default. Fail-closed extensibility: a non-null
22
+ * disabled-principal short-circuit > principal-kind bar > owner wildcard >
23
+ * exact matching row > fail-closed default. Fail-closed extensibility: a non-null
24
24
  * `constraintJson` is never treated as unconstrained, and a `resourceType`
25
25
  * mismatch/absence is never treated as a global grant.
26
26
  * See docs/decisions/DR-001-identity-and-session-boundary.md.
@@ -34,7 +34,7 @@ export interface AuthorizeContext {
34
34
  /** `authorize`'s result — always fail-closed on `allowed: false`. See docs/decisions/DR-001-identity-and-session-boundary.md. */
35
35
  export interface AuthorizeResult {
36
36
  allowed: boolean;
37
- /** Machine-readable reason: `principal_disabled | no_grant | resource_scope_mismatch | unconstrained_deny | owner_wildcard | matched`. */
37
+ /** Machine-readable reason: `principal_disabled | principal_kind_denied | no_grant | resource_scope_mismatch | unconstrained_deny | owner_wildcard | matched`. */
38
38
  reason: string;
39
39
  }
40
40
  /** Repos `authorize()`/`resolveEffectivePermissions()` read. Not a port bag with alternate adapters — see file header. */
@@ -68,10 +68,13 @@ export declare function resolveEffectivePermissions(required: {
68
68
  *
69
69
  * Precedence (behavior.spec §1.1, highest to lowest):
70
70
  * 1. Disabled principal -> `false`, `principal_disabled` (overrides all grants).
71
- * 2. An unconstrained owner `"*"` row -> `true`, `owner_wildcard`.
72
- * 3. An exact matching row (permission + workspace + resource-scope + no
71
+ * 2. A principal whose kind may not exercise `permission` (today: `member`, see
72
+ * `principal-kind-policy.ts`) -> `false`, `principal_kind_denied` (overrides all grants,
73
+ * including the owner wildcard).
74
+ * 3. An unconstrained owner `"*"` row -> `true`, `owner_wildcard`.
75
+ * 4. An exact matching row (permission + workspace + resource-scope + no
73
76
  * constraint) -> `true`, `matched`.
74
- * 4. Fail-closed default -> `false`, with the most specific diagnostic reason
77
+ * 5. Fail-closed default -> `false`, with the most specific diagnostic reason
75
78
  * available among same-permission candidates (`unconstrained_deny` over
76
79
  * `resource_scope_mismatch` over `no_grant` — an inferred, undocumented-but-
77
80
  * reasonable tie-break; the spec only pins each reason in isolation, see
@@ -1,3 +1,4 @@
1
+ import { principalKindMayExercisePermission } from "../core/principal-kind-policy.js";
1
2
  /**
2
3
  * Resolve a principal's effective permission rows: the union of (a) its
3
4
  * roles' policies' permissions and (b) policies attached directly via
@@ -46,10 +47,13 @@ function matchesRow(row, permission, context) {
46
47
  *
47
48
  * Precedence (behavior.spec §1.1, highest to lowest):
48
49
  * 1. Disabled principal -> `false`, `principal_disabled` (overrides all grants).
49
- * 2. An unconstrained owner `"*"` row -> `true`, `owner_wildcard`.
50
- * 3. An exact matching row (permission + workspace + resource-scope + no
50
+ * 2. A principal whose kind may not exercise `permission` (today: `member`, see
51
+ * `principal-kind-policy.ts`) -> `false`, `principal_kind_denied` (overrides all grants,
52
+ * including the owner wildcard).
53
+ * 3. An unconstrained owner `"*"` row -> `true`, `owner_wildcard`.
54
+ * 4. An exact matching row (permission + workspace + resource-scope + no
51
55
  * constraint) -> `true`, `matched`.
52
- * 4. Fail-closed default -> `false`, with the most specific diagnostic reason
56
+ * 5. Fail-closed default -> `false`, with the most specific diagnostic reason
53
57
  * available among same-permission candidates (`unconstrained_deny` over
54
58
  * `resource_scope_mismatch` over `no_grant` — an inferred, undocumented-but-
55
59
  * reasonable tie-break; the spec only pins each reason in isolation, see
@@ -68,6 +72,10 @@ export async function authorize(required, optional = {}) {
68
72
  if (!principal || principal.status === "disabled") {
69
73
  return { allowed: false, reason: "principal_disabled" };
70
74
  }
75
+ // Ahead of every grant read: a barred kind is denied even when a `*` row reaches it.
76
+ if (!principalKindMayExercisePermission({ kind: principal.kind, permission })) {
77
+ return { allowed: false, reason: "principal_kind_denied" };
78
+ }
71
79
  const effectiveRows = await resolveEffectivePermissions({
72
80
  deps,
73
81
  principalId,
@@ -16,11 +16,17 @@ import type { IdentityTransactionPort, PolicyPermissionRepoPort, PolicyRepoPort
16
16
  * hand-rolling a fourth divergent copy of the same fix.
17
17
  *
18
18
  * How it relates to the project:
19
- * - `registerPermissionMigration`/`listPermissionMigrations` are a small,
20
- * in-process registry — pure metadata, no I/O, mirrors
21
- * `permissions.ts`'s `PermissionCatalog.register` overwrite semantics.
19
+ * - `createPermissionMigrationRegistry` builds a small, in-process registry
20
+ * object — pure metadata, no I/O, mirrors `permissions.ts`'s
21
+ * `PermissionCatalog.register` overwrite semantics. A host creates ONE at its
22
+ * composition root and registers its own pairs on it explicitly; there is no
23
+ * module-scope registry, so which migrations exist never depends on which
24
+ * modules a process happened to import (a module-singleton filled by import
25
+ * side effects once ran a standalone script's boot reconciliation against an
26
+ * empty registry because that script never imported the registering module).
22
27
  * - `migrateDeprecatedPermissionGrants` is the actual grant fan-out: for
23
- * every registered pair, every policy holding `from` gets every string in
28
+ * every pair the caller passes in `deps.migrations` (normally its registry's
29
+ * `list({})`), every policy holding `from` gets every string in
24
30
  * `to` it doesn't already hold. It is deliberately, structurally
25
31
  * additive-only — there is no delete/update call against
26
32
  * `policy_permissions` anywhere in this file.
@@ -42,29 +48,43 @@ export type { PermissionMigration } from '../core/builtin-permissions.js';
42
48
  export interface MigrateDeprecatedPermissionGrantsDeps {
43
49
  /** Bound to the same storage as both repository ports; required before any migration work. */
44
50
  transactions: IdentityTransactionPort;
51
+ /** Every `{from, to}` pair to fan out — normally `createPermissionMigrationRegistry(...).list({})`. */
52
+ migrations: readonly PermissionMigration[];
45
53
  policyPermissions: PolicyPermissionRepoPort;
46
54
  policies: PolicyRepoPort;
47
55
  idGen: IdGenerator;
48
56
  workspaceId: UUID;
49
57
  }
50
58
  export interface MigrateDeprecatedPermissionGrantsResult {
51
- /** Total new `policy_permissions` rows written across every policy and every registered pair. */
59
+ /** Total new `policy_permissions` rows written across every policy and every passed pair. */
52
60
  migratedGrantCount: number;
53
61
  }
62
+ /** A host-owned set of `{from, to, reason}` pairs; see {@link createPermissionMigrationRegistry}. */
63
+ export interface PermissionMigrationRegistry {
64
+ /**
65
+ * Register a `{from, to, reason}` rename/split pair for later fan-out.
66
+ * Idempotent: re-registering the same `from` overwrites the prior entry
67
+ * rather than duplicating it (matches `PermissionCatalog.register`'s
68
+ * existing overwrite semantics, `identity/permissions.ts`).
69
+ *
70
+ * @complexity O(1).
71
+ */
72
+ register(migration: PermissionMigration): void;
73
+ /** Enumerate every registered migration pair, built-ins first, in first-registration order. */
74
+ list(_required: Record<string, never>): PermissionMigration[];
75
+ }
54
76
  /**
55
- * Register a `{from, to, reason}` rename/split pair for later fan-out.
56
- * Idempotent: re-registering the same `from` overwrites the prior entry
57
- * rather than duplicating it (matches `PermissionCatalog.register`'s
58
- * existing overwrite semantics, `identity/permissions.ts`).
77
+ * Create a migration registry seeded with the library's built-in pairs, then `optional.migrations`.
78
+ * Keyed by `from`, so a host pair with a built-in's `from` overwrites it rather than duplicating.
59
79
  *
60
- * @complexity O(1).
80
+ * @complexity O(b + m) where b = built-in pairs and m = `optional.migrations`.
61
81
  * @overallScore 100
62
82
  */
63
- export declare function registerPermissionMigration(migration: PermissionMigration): void;
64
- /** Enumerate every registered migration pair. */
65
- export declare function listPermissionMigrations(_required: Record<string, never>): PermissionMigration[];
83
+ export declare function createPermissionMigrationRegistry(_required?: Record<string, never>, optional?: {
84
+ migrations?: readonly PermissionMigration[];
85
+ }): PermissionMigrationRegistry;
66
86
  /**
67
- * For every registered `{from, to}` pair, for every policy in the workspace
87
+ * For every `{from, to}` pair in `deps.migrations`, for every policy in the workspace
68
88
  * holding `from`, ensure every string in `to` is also granted — adding only
69
89
  * the rows that are missing. Never removes or mutates `from` (or any other
70
90
  * existing row) — additive-only by construction. The entire fan-out runs in
@@ -75,7 +95,7 @@ export declare function listPermissionMigrations(_required: Record<string, never
75
95
  * returns `migratedGrantCount: 0` (mirrors `migrateLegacyPresentationSettings`'s
76
96
  * boot-safety precedent).
77
97
  *
78
- * @complexity O(p * m) where p = policies in the workspace, m = registered
98
+ * @complexity O(p * m) where p = policies in the workspace, m = passed
79
99
  * migration pairs; both are small, bounded collections in this app's shape.
80
100
  * @overallScore 100
81
101
  * See docs/decisions/DR-003-additive-permission-migration.md.
@@ -1,25 +1,25 @@
1
1
  import { inIdentityTransaction } from "./identity-transaction.js";
2
2
  import { BUILTIN_PERMISSION_MIGRATIONS } from '../core/builtin-permissions.js';
3
- /** Module-singleton registry — keyed by `from` so re-registration overwrites (idempotent, matches `PermissionCatalog.register`). */
4
- const registry = new Map(BUILTIN_PERMISSION_MIGRATIONS.map((migration) => [migration.from, migration]));
5
3
  /**
6
- * Register a `{from, to, reason}` rename/split pair for later fan-out.
7
- * Idempotent: re-registering the same `from` overwrites the prior entry
8
- * rather than duplicating it (matches `PermissionCatalog.register`'s
9
- * existing overwrite semantics, `identity/permissions.ts`).
4
+ * Create a migration registry seeded with the library's built-in pairs, then `optional.migrations`.
5
+ * Keyed by `from`, so a host pair with a built-in's `from` overwrites it rather than duplicating.
10
6
  *
11
- * @complexity O(1).
7
+ * @complexity O(b + m) where b = built-in pairs and m = `optional.migrations`.
12
8
  * @overallScore 100
13
9
  */
14
- export function registerPermissionMigration(migration) {
15
- registry.set(migration.from, migration);
16
- }
17
- /** Enumerate every registered migration pair. */
18
- export function listPermissionMigrations(_required) {
19
- return [...registry.values()];
10
+ export function createPermissionMigrationRegistry(_required = {}, optional = {}) {
11
+ const byFrom = new Map();
12
+ const register = (migration) => {
13
+ byFrom.set(migration.from, migration);
14
+ };
15
+ for (const migration of BUILTIN_PERMISSION_MIGRATIONS)
16
+ register(migration);
17
+ for (const migration of optional.migrations ?? [])
18
+ register(migration);
19
+ return { register, list: () => [...byFrom.values()] };
20
20
  }
21
21
  /**
22
- * For every registered `{from, to}` pair, for every policy in the workspace
22
+ * For every `{from, to}` pair in `deps.migrations`, for every policy in the workspace
23
23
  * holding `from`, ensure every string in `to` is also granted — adding only
24
24
  * the rows that are missing. Never removes or mutates `from` (or any other
25
25
  * existing row) — additive-only by construction. The entire fan-out runs in
@@ -30,13 +30,13 @@ export function listPermissionMigrations(_required) {
30
30
  * returns `migratedGrantCount: 0` (mirrors `migrateLegacyPresentationSettings`'s
31
31
  * boot-safety precedent).
32
32
  *
33
- * @complexity O(p * m) where p = policies in the workspace, m = registered
33
+ * @complexity O(p * m) where p = policies in the workspace, m = passed
34
34
  * migration pairs; both are small, bounded collections in this app's shape.
35
35
  * @overallScore 100
36
36
  * See docs/decisions/DR-003-additive-permission-migration.md.
37
37
  */
38
38
  export async function migrateDeprecatedPermissionGrants(deps) {
39
- const migrations = listPermissionMigrations({});
39
+ const { migrations } = deps;
40
40
  if (migrations.length === 0)
41
41
  return { migratedGrantCount: 0 };
42
42
  return inIdentityTransaction({
@@ -126,7 +126,12 @@ const BUILTIN_ADMIN_PERMISSIONS = [
126
126
  // directly at seed — a distinct grant from settings.write, not owner-only (unlike user.manage/
127
127
  // role.manage below).
128
128
  "workspace.manage",
129
- // Owner-only per : "user.manage", "role.manage" are deliberately absent. See docs/decisions/DR-001-identity-and-session-boundary.md.
129
+ // Roles & Permissions: admin holds role.manage (owner decision 2026-10-05, reversing DR-001's
130
+ // owner-only placement for this one string). The grant service's issuer clamp still refuses any
131
+ // grant beyond what the caller holds (GRANT_EXCEEDS_ISSUER), so an admin cannot hand out owner.
132
+ // Hosts reconcile already-seeded workspaces themselves; seedIdentity never revisits them.
133
+ "role.manage",
134
+ // Owner-only per DR-001: "user.manage" is deliberately absent. See docs/decisions/DR-001-identity-and-session-boundary.md.
130
135
  ];
131
136
  const BUILTIN_EDITOR_PERMISSIONS = [
132
137
  "content.read",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jini-ai/user-management",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Runtime-separated user management contracts and Node authentication, authorization, and administration services.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -86,13 +86,17 @@
86
86
  "access": "public",
87
87
  "registry": "https://registry.npmjs.org"
88
88
  },
89
+ "dependencies": {
90
+ "@jini-ai/core": "^0.4.2"
91
+ },
89
92
  "peerDependencies": {
90
93
  "argon2": "^0.44.0",
91
94
  "react": "^18.3.0 || ^19.0.0",
92
95
  "react-dom": "^18.3.0 || ^19.0.0",
93
96
  "@jini-ai/ui": "^0.4.0",
94
97
  "@jini-ai/admin": "^0.4.0",
95
- "@jini-ai/agentic": "^0.4.0"
98
+ "@jini-ai/agentic": "^0.4.0",
99
+ "@jini-ai/ui-kit": "^0.1.1"
96
100
  },
97
101
  "peerDependenciesMeta": {
98
102
  "argon2": {
@@ -112,6 +116,9 @@
112
116
  },
113
117
  "@jini-ai/agentic": {
114
118
  "optional": true
119
+ },
120
+ "@jini-ai/ui-kit": {
121
+ "optional": true
115
122
  }
116
123
  },
117
124
  "devDependencies": {
@@ -127,14 +134,12 @@
127
134
  "@testing-library/user-event": "^14.5.0",
128
135
  "@testing-library/jest-dom": "^6.6.3",
129
136
  "jsdom": "^25.0.1",
130
- "@jini-ai/agentic": "0.4.0",
131
- "@jini-ai/admin": "0.4.0",
132
- "@jini-ai/cms": "0.4.0",
133
- "@jini-ai/ui": "0.4.0"
134
- },
135
- "dependencies": {
136
- "@jini-ai/core": "0.4.0",
137
- "@jini-ai/ui-kit": "0.1.0"
137
+ "@jini-ai/ui": "0.4.5",
138
+ "@jini-ai/cms": "0.5.3",
139
+ "@jini-ai/core": "^0.4.2",
140
+ "@jini-ai/ui-kit": "^0.1.2",
141
+ "@jini-ai/agentic": "0.4.3",
142
+ "@jini-ai/admin": "0.4.3"
138
143
  },
139
144
  "scripts": {
140
145
  "build": "tsc -p tsconfig.json",