@jini-ai/user-management 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/LICENSE +201 -0
  3. package/README.md +104 -0
  4. package/dist/admin/adapters/http.d.ts +29 -0
  5. package/dist/admin/adapters/http.js +69 -0
  6. package/dist/admin/adapters/memory.d.ts +61 -0
  7. package/dist/admin/adapters/memory.js +217 -0
  8. package/dist/admin/auth.module.d.ts +30 -0
  9. package/dist/admin/auth.module.js +12 -0
  10. package/dist/admin/conformance/index.d.ts +56 -0
  11. package/dist/admin/conformance/index.js +156 -0
  12. package/dist/admin/controllers/auth.controller.d.ts +23 -0
  13. package/dist/admin/controllers/auth.controller.js +33 -0
  14. package/dist/admin/controllers/controller-store.d.ts +18 -0
  15. package/dist/admin/controllers/controller-store.js +23 -0
  16. package/dist/admin/controllers/members.controller.d.ts +33 -0
  17. package/dist/admin/controllers/members.controller.js +119 -0
  18. package/dist/admin/controllers/roles.controller.d.ts +47 -0
  19. package/dist/admin/controllers/roles.controller.js +282 -0
  20. package/dist/admin/controllers/users.controller.d.ts +53 -0
  21. package/dist/admin/controllers/users.controller.js +220 -0
  22. package/dist/admin/index.d.ts +21 -0
  23. package/dist/admin/index.js +15 -0
  24. package/dist/admin/members.module.d.ts +34 -0
  25. package/dist/admin/members.module.js +13 -0
  26. package/dist/admin/messages.en.d.ts +37 -0
  27. package/dist/admin/messages.en.js +21 -0
  28. package/dist/admin/models.d.ts +133 -0
  29. package/dist/admin/models.js +2 -0
  30. package/dist/admin/people.rules.d.ts +29 -0
  31. package/dist/admin/people.rules.js +51 -0
  32. package/dist/admin/ports.d.ts +187 -0
  33. package/dist/admin/ports.js +17 -0
  34. package/dist/admin/react/components/DestructiveDialog.d.ts +5 -0
  35. package/dist/admin/react/components/DestructiveDialog.js +6 -0
  36. package/dist/admin/react/components/MemberRow.d.ts +5 -0
  37. package/dist/admin/react/components/MemberRow.js +8 -0
  38. package/dist/admin/react/components/MembersConfirmation.d.ts +2 -0
  39. package/dist/admin/react/components/MembersConfirmation.js +8 -0
  40. package/dist/admin/react/components/PermissionPanel.d.ts +5 -0
  41. package/dist/admin/react/components/PermissionPanel.js +9 -0
  42. package/dist/admin/react/components/PolicyRow.d.ts +5 -0
  43. package/dist/admin/react/components/PolicyRow.js +11 -0
  44. package/dist/admin/react/components/RoleRow.d.ts +5 -0
  45. package/dist/admin/react/components/RoleRow.js +10 -0
  46. package/dist/admin/react/components/UserRow.d.ts +5 -0
  47. package/dist/admin/react/components/UserRow.js +8 -0
  48. package/dist/admin/react/components/UsersConfirmation.d.ts +3 -0
  49. package/dist/admin/react/components/UsersConfirmation.js +9 -0
  50. package/dist/admin/react/hooks/AuthBinding.hooks.d.ts +39 -0
  51. package/dist/admin/react/hooks/AuthBinding.hooks.js +21 -0
  52. package/dist/admin/react/hooks/LoginPage.hooks.d.ts +54 -0
  53. package/dist/admin/react/hooks/LoginPage.hooks.js +23 -0
  54. package/dist/admin/react/hooks/MembersBinding.hooks.d.ts +35 -0
  55. package/dist/admin/react/hooks/MembersBinding.hooks.js +21 -0
  56. package/dist/admin/react/hooks/MembersPage.hooks.d.ts +99 -0
  57. package/dist/admin/react/hooks/MembersPage.hooks.js +38 -0
  58. package/dist/admin/react/hooks/PeopleController.hooks.d.ts +18 -0
  59. package/dist/admin/react/hooks/PeopleController.hooks.js +20 -0
  60. package/dist/admin/react/hooks/PermissionPanel.hooks.d.ts +30 -0
  61. package/dist/admin/react/hooks/PermissionPanel.hooks.js +15 -0
  62. package/dist/admin/react/hooks/PoliciesTab.hooks.d.ts +47 -0
  63. package/dist/admin/react/hooks/PoliciesTab.hooks.js +26 -0
  64. package/dist/admin/react/hooks/RolesBinding.hooks.d.ts +39 -0
  65. package/dist/admin/react/hooks/RolesBinding.hooks.js +23 -0
  66. package/dist/admin/react/hooks/RolesController.hooks.d.ts +43 -0
  67. package/dist/admin/react/hooks/RolesController.hooks.js +25 -0
  68. package/dist/admin/react/hooks/RolesPage.hooks.d.ts +105 -0
  69. package/dist/admin/react/hooks/RolesPage.hooks.js +33 -0
  70. package/dist/admin/react/hooks/RolesTab.hooks.d.ts +35 -0
  71. package/dist/admin/react/hooks/RolesTab.hooks.js +19 -0
  72. package/dist/admin/react/hooks/UsersBinding.hooks.d.ts +36 -0
  73. package/dist/admin/react/hooks/UsersBinding.hooks.js +21 -0
  74. package/dist/admin/react/hooks/UsersPage.hooks.d.ts +106 -0
  75. package/dist/admin/react/hooks/UsersPage.hooks.js +22 -0
  76. package/dist/admin/react/hooks/UsersTab.hooks.d.ts +101 -0
  77. package/dist/admin/react/hooks/UsersTab.hooks.js +53 -0
  78. package/dist/admin/react/index.d.ts +203 -0
  79. package/dist/admin/react/index.js +32 -0
  80. package/dist/admin/react/pages/LoginPage.d.ts +3 -0
  81. package/dist/admin/react/pages/LoginPage.js +13 -0
  82. package/dist/admin/react/pages/MembersPage.d.ts +3 -0
  83. package/dist/admin/react/pages/MembersPage.js +19 -0
  84. package/dist/admin/react/pages/RolesPage.d.ts +3 -0
  85. package/dist/admin/react/pages/RolesPage.js +19 -0
  86. package/dist/admin/react/pages/UsersPage.d.ts +3 -0
  87. package/dist/admin/react/pages/UsersPage.js +19 -0
  88. package/dist/admin/react/tabs/AuthTab.d.ts +4 -0
  89. package/dist/admin/react/tabs/AuthTab.js +8 -0
  90. package/dist/admin/react/tabs/MembersTab.d.ts +4 -0
  91. package/dist/admin/react/tabs/MembersTab.js +9 -0
  92. package/dist/admin/react/tabs/PoliciesTab.d.ts +4 -0
  93. package/dist/admin/react/tabs/PoliciesTab.js +9 -0
  94. package/dist/admin/react/tabs/RolesTab.d.ts +4 -0
  95. package/dist/admin/react/tabs/RolesTab.js +9 -0
  96. package/dist/admin/react/tabs/UsersTab.d.ts +4 -0
  97. package/dist/admin/react/tabs/UsersTab.js +9 -0
  98. package/dist/admin/roles.module.d.ts +48 -0
  99. package/dist/admin/roles.module.js +15 -0
  100. package/dist/admin/rules.d.ts +32 -0
  101. package/dist/admin/rules.js +32 -0
  102. package/dist/admin/users.module.d.ts +39 -0
  103. package/dist/admin/users.module.js +14 -0
  104. package/dist/core/builtin-permissions.d.ts +10 -0
  105. package/dist/core/builtin-permissions.js +402 -0
  106. package/dist/core/index.d.ts +6 -0
  107. package/dist/core/index.js +9 -0
  108. package/dist/core/permissions.d.ts +40 -0
  109. package/dist/core/permissions.js +52 -0
  110. package/dist/core/ports.d.ts +237 -0
  111. package/dist/core/ports.js +2 -0
  112. package/dist/core/runtime-ports.d.ts +8 -0
  113. package/dist/core/runtime-ports.js +2 -0
  114. package/dist/core/types.d.ts +224 -0
  115. package/dist/core/types.js +93 -0
  116. package/dist/react/auth/Login.d.ts +9 -0
  117. package/dist/react/auth/Login.js +8 -0
  118. package/dist/react/auth/hooks/use-login.hooks.d.ts +22 -0
  119. package/dist/react/auth/hooks/use-login.hooks.js +24 -0
  120. package/dist/react/errors.d.ts +10 -0
  121. package/dist/react/errors.js +13 -0
  122. package/dist/react/index.d.ts +18 -0
  123. package/dist/react/index.js +9 -0
  124. package/dist/react/members/Members.d.ts +6 -0
  125. package/dist/react/members/Members.js +38 -0
  126. package/dist/react/members/hooks/use-members.hooks.d.ts +32 -0
  127. package/dist/react/members/hooks/use-members.hooks.js +120 -0
  128. package/dist/react/members/rules.d.ts +26 -0
  129. package/dist/react/members/rules.js +35 -0
  130. package/dist/react/models.d.ts +50 -0
  131. package/dist/react/models.js +2 -0
  132. package/dist/react/ports.d.ts +157 -0
  133. package/dist/react/ports.js +2 -0
  134. package/dist/react/roles/Roles.d.ts +100 -0
  135. package/dist/react/roles/Roles.hooks.d.ts +17 -0
  136. package/dist/react/roles/Roles.hooks.js +40 -0
  137. package/dist/react/roles/Roles.js +150 -0
  138. package/dist/react/roles/hooks/use-roles.hooks.d.ts +80 -0
  139. package/dist/react/roles/hooks/use-roles.hooks.js +303 -0
  140. package/dist/react/roles/rules.d.ts +35 -0
  141. package/dist/react/roles/rules.js +31 -0
  142. package/dist/react/roles/text.d.ts +27 -0
  143. package/dist/react/roles/text.js +13 -0
  144. package/dist/react/testing.d.ts +51 -0
  145. package/dist/react/testing.js +275 -0
  146. package/dist/react/users/Users.d.ts +72 -0
  147. package/dist/react/users/Users.js +129 -0
  148. package/dist/react/users/hooks/use-reset-password-fields.hooks.d.ts +16 -0
  149. package/dist/react/users/hooks/use-reset-password-fields.hooks.js +21 -0
  150. package/dist/react/users/hooks/use-users.hooks.d.ts +77 -0
  151. package/dist/react/users/hooks/use-users.hooks.js +286 -0
  152. package/dist/react/users/rules.d.ts +33 -0
  153. package/dist/react/users/rules.js +61 -0
  154. package/dist/server/admin-crud-service.d.ts +295 -0
  155. package/dist/server/admin-crud-service.js +561 -0
  156. package/dist/server/agent-tool-input.d.ts +58 -0
  157. package/dist/server/agent-tool-input.js +158 -0
  158. package/dist/server/agent-tool-registrations.d.ts +51 -0
  159. package/dist/server/agent-tool-registrations.js +417 -0
  160. package/dist/server/agent-tools.d.ts +88 -0
  161. package/dist/server/agent-tools.js +229 -0
  162. package/dist/server/auth-service.d.ts +107 -0
  163. package/dist/server/auth-service.js +170 -0
  164. package/dist/server/authorize.d.ts +92 -0
  165. package/dist/server/authorize.js +96 -0
  166. package/dist/server/grant-service.d.ts +219 -0
  167. package/dist/server/grant-service.js +419 -0
  168. package/dist/server/hasher.d.ts +64 -0
  169. package/dist/server/hasher.js +53 -0
  170. package/dist/server/identity-transaction.d.ts +9 -0
  171. package/dist/server/identity-transaction.js +8 -0
  172. package/dist/server/index.d.ts +18 -0
  173. package/dist/server/index.js +20 -0
  174. package/dist/server/messages.d.ts +7 -0
  175. package/dist/server/messages.js +5 -0
  176. package/dist/server/password-policy.d.ts +59 -0
  177. package/dist/server/password-policy.js +66 -0
  178. package/dist/server/permission-migrations.d.ts +84 -0
  179. package/dist/server/permission-migrations.js +77 -0
  180. package/dist/server/repo.memory-transactions.d.ts +16 -0
  181. package/dist/server/repo.memory-transactions.js +79 -0
  182. package/dist/server/repo.memory.d.ts +186 -0
  183. package/dist/server/repo.memory.js +230 -0
  184. package/dist/server/seed.d.ts +56 -0
  185. package/dist/server/seed.js +435 -0
  186. package/dist/server/session-tokens.d.ts +10 -0
  187. package/dist/server/session-tokens.js +12 -0
  188. package/dist/server/username.d.ts +14 -0
  189. package/dist/server/username.js +14 -0
  190. package/package.json +144 -0
@@ -0,0 +1,419 @@
1
+ import { nowIso as kernelNowIso } from "@jini-ai/core/primitives";
2
+ import { inIdentityTransaction } from "./identity-transaction.js";
3
+ import { authorize, resolveEffectivePermissions } from "./authorize.js";
4
+ import { GrantExceedsIssuerError, IdentityConflictError, IdentityForbiddenError, IdentityNotFoundError, IdentityValidationError, OwnerRequiredError, } from "../core/types.js";
5
+ import { normalizeUsername } from "./username.js";
6
+ import { validatePasswordPolicy } from "./password-policy.js";
7
+ /**
8
+ * @file Human grant-writing transitions (state.spec.md §3): `CREATE_USER`,
9
+ * `CREATE_ROLE`, `CREATE_POLICY`, `ASSIGN_ROLE`, `ATTACH_POLICY`.
10
+ *
11
+ * Purpose:
12
+ * The only usable identity principal before this file was the seeded owner —
13
+ * there was no API path to create a second user or grant/change permissions.
14
+ * These five functions are that path, following `auth-service.ts`'s exact
15
+ * shape (`{ deps: AuthServiceDeps, input: {...} }`, one exported async
16
+ * function per transition, plain `Error` subclasses for control flow).
17
+ *
18
+ * Out of scope (deferred, see the dispatch directive): `ISSUE_API_KEY`,
19
+ * `CREATE_PRINCIPAL` (api_key/agent principal creation), agent/api_key
20
+ * delegation, `WRITE_POLICY_PERMISSION`, `DISABLE_PRINCIPAL`.
21
+ *
22
+ * Architectural role:
23
+ * Ordinary core functions, not ports (mirrors `auth-service.ts`'s reasoning —
24
+ * grant-writing logic has one implementation). Each function calls
25
+ * `authorize`/`resolveEffectivePermissions` from `authorize.ts` itself
26
+ * (rather than pushing the gate into the route layer, the way
27
+ * `members/disable.ts`'s route does) so the caller-permission gate and the
28
+ * clamp are exercised directly by unit tests, no HTTP harness needed.
29
+ * See docs/decisions/DR-002-grant-authority-and-admin-safety.md.
30
+ */
31
+ /**
32
+ * Assemble the `AuthorizeDeps` bag `authorize()`/`resolveEffectivePermissions()` expect from the
33
+ * flat `IdentityRepos` bag. Exported so `admin-crud-service.ts`'s new transitions
34
+ * (`ENABLE_PRINCIPAL`/`UPDATE_USER`/`RESET_USER_PASSWORD`/`UPDATE_ROLE`/`UPDATE_POLICY`/
35
+ * `DELETE_ROLE`/`DELETE_POLICY`/`WRITE_POLICY_PERMISSION`/`DISABLE_PRINCIPAL`) reuse the identical
36
+ * gate/clamp logic rather than duplicating it — same file-split reasoning `auth-service.ts` and
37
+ * `grant-service.ts` already use (one shared helper set, two transition files).
38
+ */
39
+ export function authorizeDepsFrom(repos) {
40
+ return {
41
+ principals: repos.principals,
42
+ principalRoles: repos.principalRoles,
43
+ rolePolicies: repos.rolePolicies,
44
+ principalPolicies: repos.principalPolicies,
45
+ policyPermissions: repos.policyPermissions,
46
+ };
47
+ }
48
+ /** True iff `principalId` holds the owner wildcard `*` (unconstrained) in its effective set. */
49
+ export async function principalHoldsOwnerWildcard(required) {
50
+ const { deps, workspaceId, principalId } = required;
51
+ const effectiveRows = await resolveEffectivePermissions({
52
+ deps: authorizeDepsFrom(deps.repos),
53
+ principalId,
54
+ workspaceId,
55
+ });
56
+ return effectiveRows.some((row) => row.permission === "*" && row.resourceType == null && row.constraintJson == null);
57
+ }
58
+ /** Owner-account writes reuse the same unconstrained wildcard test as the active-owner floor. */
59
+ export async function assertOwnerTargetMayBeModified(required) {
60
+ const { deps, workspaceId, callerPrincipalId, principalId } = required;
61
+ if (!(await principalHoldsOwnerWildcard({ deps, workspaceId, principalId })))
62
+ return;
63
+ // Delegated management must never become authority over an owner's account or credentials.
64
+ if (!(await principalHoldsOwnerWildcard({ deps, workspaceId, principalId: callerPrincipalId }))) {
65
+ throw new OwnerRequiredError({ message: "only an owner can modify an owner principal" });
66
+ }
67
+ }
68
+ /**
69
+ * Fail-closed caller-permission gate shared by every transition in this file.
70
+ * Throws `IdentityForbiddenError` (403 `FORBIDDEN`) unless the caller holds
71
+ * at least one of `permissions` (an OR gate where the transition permits it;
72
+ * operator creation requires `user.manage`, while self-profile edits retain their existing OR).
73
+ *
74
+ * @complexity O(p) `authorize()` calls, p = `permissions.length` (1 or 2 in
75
+ * every caller this pass) — each itself O(resolveEffectivePermissions), see
76
+ * that function's doc.
77
+ * @overallScore 100
78
+ */
79
+ export async function assertCallerHasAnyPermission(required) {
80
+ const { deps, workspaceId, callerPrincipalId, permissions } = required;
81
+ let lastReason = "no_grant";
82
+ for (const permission of permissions) {
83
+ const result = await authorize({
84
+ deps: authorizeDepsFrom(deps.repos),
85
+ principalId: callerPrincipalId,
86
+ permission,
87
+ context: { workspaceId },
88
+ });
89
+ if (result.allowed)
90
+ return;
91
+ lastReason = result.reason;
92
+ }
93
+ throw new IdentityForbiddenError({ message: `principal '${callerPrincipalId}' is not authorized for any of [${permissions.join(", ")}]`, permission: permissions.join("|"), reason: lastReason });
94
+ }
95
+ /** True iff `effectiveRows` contains an unconstrained hold of `permission` — an exact match with
96
+ * `resourceType`/`constraintJson` both null, or the owner wildcard `*` (which counts as an
97
+ * unconstrained hold of every permission).
98
+ * See docs/decisions/DR-002-grant-authority-and-admin-safety.md.
99
+ */
100
+ function holdsUnconstrained(effectiveRows, permission) {
101
+ const hasWildcard = effectiveRows.some((row) => row.permission === "*" && row.resourceType == null && row.constraintJson == null);
102
+ if (hasWildcard)
103
+ return true;
104
+ return effectiveRows.some((row) => row.permission === permission && row.resourceType == null && row.constraintJson == null);
105
+ }
106
+ /**
107
+ * The grant-authority clamp (feature.spec.md, state.spec §3
108
+ * `ASSIGN_ROLE`/`ATTACH_POLICY` rows): every permission in `conferredPermissions`
109
+ * must be a matching effective row the caller holds unconstrained
110
+ * (`resourceType`/`constraintJson` both null; owner's `*` qualifies for all).
111
+ * A permission held only conditionally (resource-scoped or constraint-bound)
112
+ * cannot be delegated. Throws `GrantExceedsIssuerError` (403
113
+ * `GRANT_EXCEEDS_ISSUER`) naming every offending permission, and writes no
114
+ * grant row (caller does the write only after this resolves).
115
+ *
116
+ * @complexity O(resolveEffectivePermissions) + O(k) local filtering, k =
117
+ * `conferredPermissions.length` (bounded by one role's/policy's permission
118
+ * rows — a handful, per `identity/INFO.md`'s scale assumptions).
119
+ * @overallScore 100
120
+ * See docs/decisions/DR-002-grant-authority-and-admin-safety.md.
121
+ */
122
+ export async function assertGrantClamp(required) {
123
+ const { deps, workspaceId, callerPrincipalId, conferredPermissions } = required;
124
+ const distinctPermissions = [...new Set(conferredPermissions)];
125
+ if (distinctPermissions.length === 0)
126
+ return;
127
+ const effectiveRows = await resolveEffectivePermissions({
128
+ deps: authorizeDepsFrom(deps.repos),
129
+ principalId: callerPrincipalId,
130
+ workspaceId,
131
+ });
132
+ const offendingPermissions = distinctPermissions.filter((permission) => !holdsUnconstrained(effectiveRows, permission));
133
+ if (offendingPermissions.length > 0) {
134
+ throw new GrantExceedsIssuerError({ message: `principal '${callerPrincipalId}' cannot grant permission(s) it does not hold unconstrained: ${offendingPermissions.join(", ")}`, offendingPermissions: offendingPermissions });
135
+ }
136
+ }
137
+ /**
138
+ * `ASSIGN_ROLE`/`ATTACH_POLICY`'s shared human-target-only rule :
139
+ * machine (`api_key`/`system`) principal authority is set solely at
140
+ * issuance, never mutated by a later human-grant transition. Throws
141
+ * `IdentityValidationError` (400 `VALIDATION_ERROR`) for a non-`user` target;
142
+ * a missing target throws `IdentityNotFoundError`. This check runs *before* the clamp ( note:
143
+ * "does not reach this clamp").
144
+ * See docs/decisions/DR-002-grant-authority-and-admin-safety.md.
145
+ */
146
+ async function findAssignableTargetOrThrow(required) {
147
+ const { deps, workspaceId, principalId, transitionName } = required;
148
+ const target = await deps.repos.principals.findById({ workspaceId, id: principalId });
149
+ if (!target) {
150
+ throw new IdentityNotFoundError({ message: `principal '${principalId}' was not found` });
151
+ }
152
+ if (target.kind !== "user") {
153
+ throw new IdentityValidationError({ message: `${transitionName} target must be a human (kind='user') principal, got kind='${target.kind}'` });
154
+ }
155
+ return target;
156
+ }
157
+ /**
158
+ * `CREATE_USER` (state.spec §3). Atomically mints a NEW
159
+ * `kind='user'` principal and its `users` row — the `principalId` is always
160
+ * generated here, never caller-supplied, so this transition can never attach
161
+ * a credential to a pre-existing principal (the privilege-escalation
162
+ * class: an earlier draft let an admin bind a password to the seeded owner
163
+ * principal and log in as owner). Gated by `user.manage`; front-end
164
+ * `member.manage` never authorizes minting an operator credential.
165
+ *
166
+ * "Atomically" here means structurally, not via a DB transaction — the
167
+ * in-memory adapters have no cross-repo transaction (same disclosed gap as
168
+ * `seed.ts`/`executeCommand`'s compensating-rollback path). The invariant
169
+ * that actually matters (no credential ever attaches to an existing
170
+ * principal) holds regardless, because `principalId` is generated inline and
171
+ * the password is hashed before either row is written, so a hashing failure
172
+ * leaves no orphan principal. A future SQLite adapter wraps both writes in
173
+ * one real transaction (port/adapter rule-of-two) without changing this contract.
174
+ *
175
+ * @complexity O(1) — one permission check, one username lookup, one hash,
176
+ * two saves.
177
+ * @overallScore 100
178
+ * See docs/decisions/DR-002-grant-authority-and-admin-safety.md.
179
+ */
180
+ export async function createUser(required, optional = {}) {
181
+ const { deps } = required;
182
+ const input = { ...required.input, ...optional };
183
+ await assertCallerHasAnyPermission({
184
+ deps,
185
+ workspaceId: input.workspaceId,
186
+ callerPrincipalId: input.callerPrincipalId,
187
+ // Member administration must not mint principals that can log into the operator plane.
188
+ permissions: ["user.manage"],
189
+ });
190
+ const username = normalizeUsername({ raw: input.username ?? "" });
191
+ if (!username || !input.password) {
192
+ throw new IdentityValidationError({ message: "username and password are required" });
193
+ }
194
+ // Presence-and-upper-bound-only policy (MSG-04, no minimum length) — never reaches seed.ts's
195
+ // owner password, see password-policy.ts's own header for why that separation is deliberate.
196
+ const passwordError = validatePasswordPolicy({ password: input.password });
197
+ if (passwordError) {
198
+ throw new IdentityValidationError({ message: passwordError });
199
+ }
200
+ const existingUser = await deps.repos.users.findByUsername({ workspaceId: input.workspaceId, username });
201
+ if (existingUser) {
202
+ throw new IdentityConflictError({ message: `username '${username}' is already in use` });
203
+ }
204
+ const passwordHash = await deps.hasher.hash({ password: input.password });
205
+ const nowIso = kernelNowIso({ clock: deps.clock });
206
+ const principalId = deps.idGen.newId();
207
+ const principal = {
208
+ id: principalId,
209
+ workspaceId: input.workspaceId,
210
+ kind: "user",
211
+ displayName: username,
212
+ status: "active",
213
+ createdAt: nowIso,
214
+ };
215
+ const user = {
216
+ principalId,
217
+ workspaceId: input.workspaceId,
218
+ username,
219
+ email: input.email,
220
+ passwordHash,
221
+ };
222
+ await deps.repos.principals.save(principal);
223
+ await deps.repos.users.save(user);
224
+ return { principal, user };
225
+ }
226
+ /**
227
+ * `CREATE_ROLE` (state.spec §3). Gated by `role.manage`. Always mints
228
+ * `isBuiltin=false` — built-ins are SEED-only and immutable.
229
+ *
230
+ * Deviation from the state.spec §3 table (disclosed): that row lists
231
+ * `name`, `description?` jointly for `CREATE_ROLE`/`CREATE_POLICY`, but
232
+ * `RoleRecord` (types.ts, frozen before this task) has no `description`
233
+ * field — only `PolicyRecord` does. `createRole` therefore takes `name`
234
+ * only; `createPolicy` below takes both.
235
+ *
236
+ * @complexity O(1) — one permission check, one save.
237
+ * @overallScore 100
238
+ * See docs/decisions/DR-002-grant-authority-and-admin-safety.md.
239
+ */
240
+ export async function createRole(required) {
241
+ const { deps, input } = required;
242
+ await assertCallerHasAnyPermission({
243
+ deps,
244
+ workspaceId: input.workspaceId,
245
+ callerPrincipalId: input.callerPrincipalId,
246
+ permissions: ["role.manage"],
247
+ });
248
+ const name = (input.name ?? "").trim();
249
+ if (!name) {
250
+ throw new IdentityValidationError({ message: "role name is required" });
251
+ }
252
+ const role = {
253
+ id: deps.idGen.newId(),
254
+ workspaceId: input.workspaceId,
255
+ name,
256
+ isBuiltin: false,
257
+ };
258
+ await deps.repos.roles.save(role);
259
+ return { role };
260
+ }
261
+ /**
262
+ * `CREATE_POLICY` (state.spec §3). Gated by `role.manage`. Always mints
263
+ * `isBuiltin=false` and `isFrozen=false` — only the (out-of-scope this pass)
264
+ * `ISSUE_API_KEY` transition ever mints `isFrozen=true` policies.
265
+ *
266
+ * @complexity O(1) — one permission check, one save.
267
+ * @overallScore 100
268
+ */
269
+ export async function createPolicy(required, optional = {}) {
270
+ const { deps } = required;
271
+ const input = { ...required.input, ...optional };
272
+ await assertCallerHasAnyPermission({
273
+ deps,
274
+ workspaceId: input.workspaceId,
275
+ callerPrincipalId: input.callerPrincipalId,
276
+ permissions: ["role.manage"],
277
+ });
278
+ const name = (input.name ?? "").trim();
279
+ if (!name) {
280
+ throw new IdentityValidationError({ message: "policy name is required" });
281
+ }
282
+ const policy = {
283
+ id: deps.idGen.newId(),
284
+ workspaceId: input.workspaceId,
285
+ name,
286
+ description: input.description,
287
+ isBuiltin: false,
288
+ isFrozen: false,
289
+ };
290
+ await deps.repos.policies.save(policy);
291
+ return { policy };
292
+ }
293
+ /**
294
+ * `ASSIGN_ROLE` (state.spec §3). Gated by `role.manage`;
295
+ * target must be a `kind='user'` principal; the clamp must
296
+ * pass over every permission carried by the role's policies — so
297
+ * assigning the built-in `owner` role requires the caller to already hold
298
+ * the wildcard `*` (owner-only). No row is written on any rejection.
299
+ *
300
+ * @complexity O(resolveEffectivePermissions) + O(role's rolePolicy count)
301
+ * round trips to gather conferred permissions — both bounded by the small,
302
+ * hand-authored grant graph this system assumes (see `identity/INFO.md`).
303
+ * @overallScore 100
304
+ * See docs/decisions/DR-002-grant-authority-and-admin-safety.md.
305
+ */
306
+ export async function assignRole(required) {
307
+ const { deps, input } = required;
308
+ return inIdentityTransaction({
309
+ transactions: deps.repos.transactions,
310
+ workspaceId: input.workspaceId,
311
+ execute: async () => {
312
+ await assertCallerHasAnyPermission({
313
+ deps,
314
+ workspaceId: input.workspaceId,
315
+ callerPrincipalId: input.callerPrincipalId,
316
+ permissions: ["role.manage"],
317
+ });
318
+ await findAssignableTargetOrThrow({
319
+ deps,
320
+ workspaceId: input.workspaceId,
321
+ principalId: input.principalId,
322
+ transitionName: "ASSIGN_ROLE",
323
+ });
324
+ await assertOwnerTargetMayBeModified({
325
+ deps,
326
+ workspaceId: input.workspaceId,
327
+ callerPrincipalId: input.callerPrincipalId,
328
+ principalId: input.principalId,
329
+ });
330
+ const role = await deps.repos.roles.findById({ workspaceId: input.workspaceId, id: input.roleId });
331
+ if (!role) {
332
+ throw new IdentityNotFoundError({ message: `role '${input.roleId}' was not found` });
333
+ }
334
+ const rolePolicyLinks = await deps.repos.rolePolicies.listByRoleId({
335
+ workspaceId: input.workspaceId,
336
+ roleId: input.roleId,
337
+ });
338
+ const policyPermissionGroups = await Promise.all(rolePolicyLinks.map((link) => deps.repos.policyPermissions.listByPolicyId({ workspaceId: input.workspaceId, policyId: link.policyId })));
339
+ const conferredPermissions = policyPermissionGroups.flat().map((row) => row.permission);
340
+ await assertGrantClamp({
341
+ deps,
342
+ workspaceId: input.workspaceId,
343
+ callerPrincipalId: input.callerPrincipalId,
344
+ conferredPermissions,
345
+ });
346
+ const assignment = {
347
+ id: deps.idGen.newId(),
348
+ workspaceId: input.workspaceId,
349
+ principalId: input.principalId,
350
+ roleId: input.roleId,
351
+ };
352
+ await deps.repos.principalRoles.save(assignment);
353
+ return { assignment };
354
+ },
355
+ });
356
+ }
357
+ /**
358
+ * `ATTACH_POLICY` (state.spec §3). Gated by
359
+ * `role.manage`; target must be a `kind='user'` principal; the
360
+ * clamp must pass over every permission the policy carries —
361
+ * attaching the built-in `owner` policy requires the caller to already hold
362
+ * `*` (owner-only). No row is written on any rejection.
363
+ *
364
+ * @complexity O(resolveEffectivePermissions) + O(policy's permission row
365
+ * count) — both bounded, see `assignRole`'s doc.
366
+ * @overallScore 100
367
+ * See docs/decisions/DR-002-grant-authority-and-admin-safety.md.
368
+ */
369
+ export async function attachPolicy(required) {
370
+ const { deps, input } = required;
371
+ return inIdentityTransaction({
372
+ transactions: deps.repos.transactions,
373
+ workspaceId: input.workspaceId,
374
+ execute: async () => {
375
+ await assertCallerHasAnyPermission({
376
+ deps,
377
+ workspaceId: input.workspaceId,
378
+ callerPrincipalId: input.callerPrincipalId,
379
+ permissions: ["role.manage"],
380
+ });
381
+ await findAssignableTargetOrThrow({
382
+ deps,
383
+ workspaceId: input.workspaceId,
384
+ principalId: input.principalId,
385
+ transitionName: "ATTACH_POLICY",
386
+ });
387
+ await assertOwnerTargetMayBeModified({
388
+ deps,
389
+ workspaceId: input.workspaceId,
390
+ callerPrincipalId: input.callerPrincipalId,
391
+ principalId: input.principalId,
392
+ });
393
+ const policy = await deps.repos.policies.findById({ workspaceId: input.workspaceId, id: input.policyId });
394
+ if (!policy) {
395
+ throw new IdentityNotFoundError({ message: `policy '${input.policyId}' was not found` });
396
+ }
397
+ const permissionRows = await deps.repos.policyPermissions.listByPolicyId({
398
+ workspaceId: input.workspaceId,
399
+ policyId: input.policyId,
400
+ });
401
+ const conferredPermissions = permissionRows.map((row) => row.permission);
402
+ await assertGrantClamp({
403
+ deps,
404
+ workspaceId: input.workspaceId,
405
+ callerPrincipalId: input.callerPrincipalId,
406
+ conferredPermissions,
407
+ });
408
+ const attachment = {
409
+ id: deps.idGen.newId(),
410
+ workspaceId: input.workspaceId,
411
+ principalId: input.principalId,
412
+ policyId: input.policyId,
413
+ };
414
+ await deps.repos.principalPolicies.save(attachment);
415
+ return { attachment };
416
+ },
417
+ });
418
+ }
419
+ //# sourceMappingURL=grant-service.js.map
@@ -0,0 +1,64 @@
1
+ import type { PasswordHasherPort } from "../core/ports.js";
2
+ /**
3
+ * @file argon2id password hashing.
4
+ *
5
+ * Purpose:
6
+ * The one real `PasswordHasherPort` adapter this pass — native argon2
7
+ * bindings, verified installable/buildable in this environment (see the
8
+ * Programmer handoff). Raw passwords are never stored or logged; only the
9
+ * hash produced here is persisted (`users.passwordHash`).
10
+ *
11
+ * Architectural role:
12
+ * Rule-of-two candidate per feature.spec's Dependencies table — only one
13
+ * adapter exists this pass, matching the spec's own "candidate" framing.
14
+ * See docs/decisions/DR-001-identity-and-session-boundary.md.
15
+ */
16
+ /** Tunable cost parameters, defaulted to OWASP's current argon2id minimums. */
17
+ export interface Argon2PasswordHasherOptions {
18
+ /** KiB. OWASP minimum recommendation is 19456 (~19 MiB). */
19
+ memoryCost?: number;
20
+ /** Iteration count. OWASP minimum recommendation is 2 at this memory cost. */
21
+ timeCost?: number;
22
+ /** Degree of parallelism. */
23
+ parallelism?: number;
24
+ }
25
+ /** Binding seam; hosts may inject a native adapter or another implementation. */
26
+ export interface Argon2BindingPort {
27
+ argon2id: number;
28
+ hash(required: {
29
+ password: string;
30
+ }, optional?: Argon2PasswordHasherOptions & {
31
+ type?: number;
32
+ }): Promise<string>;
33
+ verify(required: {
34
+ hash: string;
35
+ password: string;
36
+ }): Promise<boolean>;
37
+ }
38
+ export interface Argon2PasswordHasherDependencies {
39
+ loadBinding: (required: Record<string, never>) => Argon2BindingPort;
40
+ }
41
+ /** Resolve the optional native peer only when this adapter is used. */
42
+ export declare function loadArgon2Binding(_required: Record<string, never>): Argon2BindingPort;
43
+ /**
44
+ * argon2id-backed `PasswordHasherPort`. `verify` never throws on a malformed
45
+ * or foreign hash — it resolves `false`, so a corrupted `passwordHash` row
46
+ * degrades to "wrong password," never to a 500 or an authentication bypass.
47
+ *
48
+ * @complexity O(1) calls into the native binding; cost is tuned by
49
+ * `memoryCost`/`timeCost`/`parallelism`, not by input size.
50
+ * @overallScore 100
51
+ */
52
+ export declare class Argon2PasswordHasher implements PasswordHasherPort {
53
+ private readonly options;
54
+ private readonly loadBinding;
55
+ constructor({ loadBinding }: Argon2PasswordHasherDependencies, options?: Argon2PasswordHasherOptions);
56
+ hash({ password }: {
57
+ password: string;
58
+ }): Promise<string>;
59
+ verify({ hash, password }: {
60
+ hash: string;
61
+ password: string;
62
+ }): Promise<boolean>;
63
+ }
64
+ //# sourceMappingURL=hasher.d.ts.map
@@ -0,0 +1,53 @@
1
+ import { createRequire } from "node:module";
2
+ const requirePeer = createRequire(import.meta.url);
3
+ /** Resolve the optional native peer only when this adapter is used. */
4
+ export function loadArgon2Binding(_required) {
5
+ const native = requirePeer("argon2");
6
+ return {
7
+ argon2id: native.argon2id,
8
+ hash: ({ password }, optional = {}) => native.hash(password, { ...DEFAULT_OPTIONS, type: native.argon2id, ...optional }),
9
+ verify: ({ hash, password }) => native.verify(hash, password),
10
+ };
11
+ }
12
+ const DEFAULT_OPTIONS = {
13
+ memoryCost: 19456,
14
+ timeCost: 2,
15
+ parallelism: 1,
16
+ };
17
+ /**
18
+ * argon2id-backed `PasswordHasherPort`. `verify` never throws on a malformed
19
+ * or foreign hash — it resolves `false`, so a corrupted `passwordHash` row
20
+ * degrades to "wrong password," never to a 500 or an authentication bypass.
21
+ *
22
+ * @complexity O(1) calls into the native binding; cost is tuned by
23
+ * `memoryCost`/`timeCost`/`parallelism`, not by input size.
24
+ * @overallScore 100
25
+ */
26
+ export class Argon2PasswordHasher {
27
+ options;
28
+ loadBinding;
29
+ constructor({ loadBinding }, options = {}) {
30
+ this.loadBinding = loadBinding;
31
+ this.options = { ...DEFAULT_OPTIONS, ...options };
32
+ }
33
+ async hash({ password }) {
34
+ const argon2 = this.loadBinding({});
35
+ return argon2.hash({ password }, {
36
+ type: argon2.argon2id,
37
+ memoryCost: this.options.memoryCost,
38
+ timeCost: this.options.timeCost,
39
+ parallelism: this.options.parallelism,
40
+ });
41
+ }
42
+ async verify({ hash, password }) {
43
+ const argon2 = this.loadBinding({});
44
+ try {
45
+ return await argon2.verify({ hash, password });
46
+ }
47
+ catch {
48
+ // Malformed/foreign hash (e.g. a hand-edited row) — fail closed, not 500.
49
+ return false;
50
+ }
51
+ }
52
+ }
53
+ //# sourceMappingURL=hasher.js.map
@@ -0,0 +1,9 @@
1
+ import type { IdentityTransactionPort } from '../core/ports.js';
2
+ import type { UUID } from "@jini-ai/core/primitives";
3
+ /** Guarded writes never silently downgrade to independent asynchronous repository calls. */
4
+ export declare function inIdentityTransaction<T>(required: {
5
+ transactions: IdentityTransactionPort;
6
+ workspaceId: UUID;
7
+ execute: () => Promise<T>;
8
+ }): Promise<T>;
9
+ //# sourceMappingURL=identity-transaction.d.ts.map
@@ -0,0 +1,8 @@
1
+ import { IdentityTransactionRequiredError } from '../core/types.js';
2
+ /** Guarded writes never silently downgrade to independent asynchronous repository calls. */
3
+ export function inIdentityTransaction(required) {
4
+ if (!required.transactions)
5
+ throw new IdentityTransactionRequiredError({});
6
+ return required.transactions.run({ workspaceId: required.workspaceId, execute: required.execute });
7
+ }
8
+ //# sourceMappingURL=identity-transaction.js.map
@@ -0,0 +1,18 @@
1
+ /** Node identity services and adapters; host wiring stays outside this package. */
2
+ export * from "./auth-service.js";
3
+ export * from "./authorize.js";
4
+ export * from "./grant-service.js";
5
+ export * from "./admin-crud-service.js";
6
+ export * from "./seed.js";
7
+ export * from "./repo.memory.js";
8
+ export * from "./repo.memory-transactions.js";
9
+ export * from "./password-policy.js";
10
+ export * from "./hasher.js";
11
+ export * from "./username.js";
12
+ export * from "./permission-migrations.js";
13
+ export { identityAgentToolCatalog, type IdentityAgentToolDefinition, } from "./agent-tools.js";
14
+ export { parseIdentityToolInput, type IdentityToolInputResult } from "./agent-tool-input.js";
15
+ export * from "./session-tokens.js";
16
+ export { buildIdentityRegistrations, identityDerivedRisk, type IdentityToolDeps } from "./agent-tool-registrations.js";
17
+ export { defaultUserManagementMessages, type UserManagementMessages } from "./messages.js";
18
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,20 @@
1
+ /** Node identity services and adapters; host wiring stays outside this package. */
2
+ // Hosts compose concrete repositories and inject their dependency bags. Exporting database-specific
3
+ // composition here would put one host's handle and schema in every consumer's dependency closure.
4
+ export * from "./auth-service.js";
5
+ export * from "./authorize.js";
6
+ export * from "./grant-service.js";
7
+ export * from "./admin-crud-service.js";
8
+ export * from "./seed.js";
9
+ export * from "./repo.memory.js";
10
+ export * from "./repo.memory-transactions.js";
11
+ export * from "./password-policy.js";
12
+ export * from "./hasher.js";
13
+ export * from "./username.js";
14
+ export * from "./permission-migrations.js";
15
+ export { identityAgentToolCatalog, } from "./agent-tools.js";
16
+ export { parseIdentityToolInput } from "./agent-tool-input.js";
17
+ export * from "./session-tokens.js";
18
+ export { buildIdentityRegistrations, identityDerivedRisk } from "./agent-tool-registrations.js";
19
+ export { defaultUserManagementMessages } from "./messages.js";
20
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,7 @@
1
+ /** Default user-facing authentication copy. Hosts may replace it without changing error classes. */
2
+ export interface UserManagementMessages {
3
+ invalidCredentials: string;
4
+ invalidSessionTtl: string;
5
+ }
6
+ export declare const defaultUserManagementMessages: UserManagementMessages;
7
+ //# sourceMappingURL=messages.d.ts.map
@@ -0,0 +1,5 @@
1
+ export const defaultUserManagementMessages = {
2
+ invalidSessionTtl: "sessionTtlMs must be positive and finite",
3
+ invalidCredentials: "invalid username or password",
4
+ };
5
+ //# sourceMappingURL=messages.js.map
@@ -0,0 +1,59 @@
1
+ /**
2
+ * @file Password policy: no minimum length, no composition rules — presence and an upper bound only.
3
+ *
4
+ * Owner decision: the previous NIST SP 800-63B-aligned 12-character minimum has been removed.
5
+ * This module no longer enforces composition rules (mandatory uppercase, digit, or symbol) either
6
+ * — that predates this change and remains deliberate, since composition rules measurably push
7
+ * people toward predictable, guessable patterns ("Password1!") rather than genuinely stronger
8
+ * secrets. What remains is the minimum a credential system can enforce at all: a password must be
9
+ * non-empty, and is capped at `MAX_PASSWORD_LENGTH` as a defense-in-depth bound (see that
10
+ * constant's own doc).
11
+ *
12
+ * `createUser` (`grant-service.ts`) and `resetUserPassword` (`admin-crud-service.ts`) are the
13
+ * identity system's only two write paths that ever set a password, confirmed by tracing the
14
+ * whole call graph — both call this module.
15
+ *
16
+ * Two things this deliberately does NOT do, both load-bearing:
17
+ * - Never reaches `seed.ts`'s first-boot owner password. `seedIdentity` hashes it directly
18
+ * (`deps.hasher.hash(ownerPassword)`, `seed.ts:317`) and calls neither `createUser` nor
19
+ * `resetUserPassword` — confirmed before writing this module. That separation is intentional
20
+ * to preserve here: the host chooses the first-boot owner password (`SeedIdentityInput` requires
21
+ * it and supplies no default), and an existing installation is already authenticating with
22
+ * whatever it chose. Enforcing this policy on that path would lock out every such installation
23
+ * whose chosen value predates the policy.
24
+ * - Never re-validates at login. This is a write-time policy — `login`/`verify` never call it.
25
+ * An account whose password predates this policy (or was seeded) must keep authenticating.
26
+ */
27
+ /**
28
+ * Generous upper bound — not itself a security requirement (argon2id's cost is tuned by its
29
+ * `memoryCost`/`timeCost`/`parallelism` parameters, not by input size — see `hasher.ts`'s own
30
+ * `@complexity` note), but a defense-in-depth cap on how large a string this service will accept
31
+ * and pass to the hasher at all, independent of whatever limit (if any) sits in front of it at
32
+ * the HTTP layer. Comfortably above any realistic human-typed passphrase.
33
+ */
34
+ export declare const MAX_PASSWORD_LENGTH = 512;
35
+ /**
36
+ * Validates `password` against the presence-and-upper-bound-only policy.
37
+ *
38
+ * Measures length in Unicode CODE POINTS (`[...password].length`), not UTF-16 code units
39
+ * (`password.length`): a passphrase built from astral-plane characters (many emoji, some
40
+ * CJK/historic scripts) is represented as a surrogate pair per character in a JS string, so plain
41
+ * `.length` overcounts those characters relative to what a human typing them perceives — the same
42
+ * reasoning that applies to `MAX_PASSWORD_LENGTH` below applies to the emptiness check here, since
43
+ * both compare this same code-point count. No trimming, no character allowlist: every printable
44
+ * character, including leading/trailing spaces and full Unicode, is accepted as typed. Silently
45
+ * altering the input (trimming, stripping) would make the stored credential differ from the
46
+ * secret the operator actually chose.
47
+ *
48
+ * @returns `null` if `password` satisfies the policy, otherwise an operator-facing message
49
+ * stating the requirement plainly — used verbatim as the thrown `IdentityValidationError`'s
50
+ * message by both call sites, and rendered as-is through the admin's `describeApiError` layer.
51
+ *
52
+ * @complexity O(n) in the password's length — spreading a string into code points is a single
53
+ * linear pass, which is the minimum work a correct (non-UTF-16-code-unit) count requires.
54
+ * @overallScore 100
55
+ */
56
+ export declare function validatePasswordPolicy({ password }: {
57
+ password: string;
58
+ }): string | null;
59
+ //# sourceMappingURL=password-policy.d.ts.map