@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,435 @@
1
+ import { nowIso as kernelNowIso } from "@jini-ai/core/primitives";
2
+ import { normalizeUsername } from "./username.js";
3
+ /**
4
+ * @file First-boot identity seed (state.spec `SEED_FIRST_BOOT`).
5
+ *
6
+ * Purpose:
7
+ * Seeds, in one idempotent pass: a `system` principal; a disabled legacy
8
+ * `user-local` principal ( — so any pre-existing `change_sets`
9
+ * stamped `actorId='user-local'` satisfy the composite FK without rewriting
10
+ * history); four built-in roles (`owner`/`admin`/`editor`/
11
+ * `viewer`) each 1:1 with a built-in policy; and the initial `owner` user.
12
+ *
13
+ * How it relates to the project:
14
+ * - Called once by each composition root (`server/app.ts` in-memory,
15
+ * `server/deps.ts` SQLite-content-with-in-memory-identity) before the
16
+ * server accepts requests. `RouteDeps.identityReady` is this call's
17
+ * promise — auth-adjacent middleware awaits it (see `middleware/dev-auth.ts`).
18
+ *
19
+ * Architectural role:
20
+ * Seed data lives in one place so both composition roots stay identical
21
+ * (mirrors `server/seed.ts`'s reasoning for workspace/post/presentation seed
22
+ * data). Idempotent: re-seeding an already-seeded workspace is a no-op
23
+ * (checked via the owner username).
24
+ * See docs/decisions/DR-001-identity-and-session-boundary.md.
25
+ */
26
+ /** : the literal id the legacy Article VI actor stamp resolves to. See docs/decisions/DR-001-identity-and-session-boundary.md. */
27
+ const LEGACY_USER_LOCAL_PRINCIPAL_ID = "user-local";
28
+ /**
29
+ * The built-in role the seeded owner user holds, and its authority. Named constants because two
30
+ * call sites now depend on them agreeing exactly — the fresh seed below and `ensureOwnerRoleBinding`,
31
+ * which repairs an interrupted one. Drift between the two would be a silent authority bug.
32
+ *
33
+ * the owner policy holds the wildcard `*`, not an enumerated list, so it
34
+ * automatically covers permissions features register later.
35
+ * See docs/decisions/DR-001-identity-and-session-boundary.md.
36
+ */
37
+ const OWNER_ROLE_NAME = "owner";
38
+ const OWNER_ROLE_PERMISSIONS = ["*"];
39
+ const BUILTIN_ADMIN_PERMISSIONS = [
40
+ "content.read",
41
+ "content.write",
42
+ "content.publish",
43
+ "content.delete",
44
+ "theme.set",
45
+ // Editing a theme's source files is an admin capability, deliberately NOT in the editor set
46
+ // below (which keeps theme.set): an editor may switch the site between validated themes, but
47
+ // authoring template/CSS source is a build-time-shaped capability whose failure mode is a broken
48
+ // theme, not a different-looking one.
49
+ "theme.edit",
50
+ "plugin.read",
51
+ "plugin.enable",
52
+ "plugin.disable",
53
+ "changeset.read",
54
+ "changeset.revert",
55
+ "member.manage",
56
+ "settings.write",
57
+ "apikey.manage",
58
+ // Migration clause (mirrors the admin.menus.*/admin.integrations.manage
59
+ // precedent above): every freshly-seeded workspace's admin role gets the concrete media.* working
60
+ // actions directly, so it never depends on migrateDeprecatedPermissionGrants(). The umbrella
61
+ // "media.manage" is deliberately not included here (mirrors admin.menus.manage's exclusion
62
+ // above). "media.write" is deliberately dropped from THIS seed list only — the string stays
63
+ // registered (deprecated) in identity/permissions.ts.
64
+ "media.read",
65
+ "media.upload",
66
+ "media.update",
67
+ "media.delete",
68
+ "media.delete.force",
69
+ "media.download_original",
70
+ "media.upload_svg",
71
+ // Menus remediation migration clause: every freshly-seeded
72
+ // workspace gets the 6 new admin.menus.* CRUD strings directly, so it never depends on
73
+ // migrateDeprecatedPermissionGrants() for its own built-in role grants. The now-legacy
74
+ // "navigation.manage" is deliberately dropped from THIS seed list only — the string itself
75
+ // stays registered (deprecated) in identity/permissions.ts. migrateDeprecatedPermissionGrants()
76
+ // now IS wired into live boot (identity/wiring.ts, chained right after seedIdentity resolves)
77
+ // so any pre-existing policy still holding navigation.manage/integration.manage also gains
78
+ // the new string(s) — this fresh-seed list just doesn't need to depend on that fan-out for its
79
+ // own built-in role grants.
80
+ "admin.menus.read",
81
+ "admin.menus.create",
82
+ "admin.menus.update",
83
+ "admin.menus.delete",
84
+ "admin.menus.delete.force",
85
+ "admin.menus.assign",
86
+ // Migration clause (mirrors the admin.menus.* precedent immediately
87
+ // above): every freshly-seeded workspace gets admin.integrations.manage directly, so it never
88
+ // depends on migrateDeprecatedPermissionGrants(). "integration.manage" is deliberately dropped
89
+ // from THIS seed list only — the string stays registered (deprecated) in permissions.ts.
90
+ "admin.integrations.manage",
91
+ // Migration clause: every settings.write holder also
92
+ // gets settings.definitions.manage, so the admin role isn't left
93
+ // fail-closed-locked-out of definition-lifecycle operations it previously
94
+ // reached through the coarse settings.write grant. No separate migration
95
+ // script is needed pre-launch — this seed function is the sole source of
96
+ // built-in role grants (no existing installation's data to migrate yet).
97
+ "settings.definitions.manage",
98
+ "settings.global.write",
99
+ "settings.workspace.write",
100
+ "settings.user.self.write",
101
+ "settings.user.write",
102
+ // Internal audit F2 remediation (2026-07-29): granted directly here so a freshly-seeded
103
+ // workspace never depends on the `settings.user.write` -> `settings.user.read` fan-out in
104
+ // permissions.ts (same "don't depend on migrateDeprecatedPermissionGrants for our own built-in
105
+ // role grants" discipline as the admin.menus.*/admin.integrations.manage clauses above). The
106
+ // fan-out exists for ALREADY-seeded installations, which this list cannot reach because
107
+ // seedIdentity early-returns once an owner user exists.
108
+ "settings.user.read",
109
+ "settings.reset.global",
110
+ "settings.reset.workspace",
111
+ "settings.reset.user",
112
+ "settings.read",
113
+ "settings.read.raw",
114
+ "settings.read.revisions",
115
+ "settings.read.definitions",
116
+ // Every freshly-seeded workspace's built-in admin role
117
+ // gets the one SEO umbrella permission directly, mirroring the admin.menus.* precedent above.
118
+ "admin.seo.manage",
119
+ // The AI Assistant section's umbrella permission, granted directly here for the same reason
120
+ // `admin.seo.manage` above is: a freshly-seeded workspace's built-in admin must be able to reach
121
+ // the public assistant's master switch without depending on any deprecated-grant fan-out. Turning
122
+ // the public assistant ON is still a deliberate act — the SETTING defaults to off
123
+ // (`assistant/public-assistant-settings.ts`); this only grants the ability to flip it.
124
+ "admin.assistant.manage",
125
+ // Workspace Administration: admin gets workspace.manage
126
+ // directly at seed — a distinct grant from settings.write, not owner-only (unlike user.manage/
127
+ // role.manage below).
128
+ "workspace.manage",
129
+ // Owner-only per : "user.manage", "role.manage" are deliberately absent. See docs/decisions/DR-001-identity-and-session-boundary.md.
130
+ ];
131
+ const BUILTIN_EDITOR_PERMISSIONS = [
132
+ "content.read",
133
+ "content.write",
134
+ "content.publish",
135
+ "content.delete",
136
+ // Migration clause: editor gets the ordinary media working actions
137
+ // (read/upload/update/trash) but, unlike admin above, NOT media.delete.force (destructive
138
+ // hard-purge), media.download_original (mint-only access to sensitive originals), or
139
+ // media.upload_svg (XSS-risk-gated capability) — mirrors the Forms admin.forms.manage vs
140
+ // admin.forms.submissions.* PII-split precedent rather than granting editor
141
+ // everything admin.write's single flat string previously implied. "media.write" is deliberately
142
+ // dropped from THIS seed list only — the string stays registered (deprecated) in
143
+ // identity/permissions.ts. Still a subset of admin's media.* set above ( owner ⊇ admin ⊇. See docs/decisions/DR-001-identity-and-session-boundary.md.
144
+ // editor subset property).
145
+ "media.read",
146
+ "media.upload",
147
+ "media.update",
148
+ "media.delete",
149
+ "theme.set",
150
+ ];
151
+ const BUILTIN_VIEWER_PERMISSIONS = ["content.read", "changeset.read", "plugin.read"];
152
+ /**
153
+ * Why every step below is "find, then save only what is missing" rather than a straight insert.
154
+ *
155
+ * This function's writes are NOT transactional and its caller is not awaited by its hosts (one host
156
+ * hands `seedIdentity`'s promise back as `RouteDeps.identityReady` and returns synchronously), so
157
+ * a process that exits before the seed finishes — a fast-failing CLI command, a Ctrl-C, a crash —
158
+ * leaves the workspace half-seeded. `seedIdentity`'s own early-return guard keys on the OWNER USER,
159
+ * which is written last, so the next boot correctly decides the seed is unfinished and replays it.
160
+ * A replay that blindly re-inserted would hit `UNIQUE(workspace_id, name)` on `roles`/`policies`
161
+ * (crashing every subsequent boot, permanently), and would silently duplicate `role_policies` /
162
+ * `policy_permissions` rows, which carry no unique index at all. So the replay must converge onto
163
+ * the rows already there instead of re-creating them.
164
+ *
165
+ * What this deliberately does NOT do: adopt a role or policy an operator created themselves. A
166
+ * same-named non-built-in row is a genuine conflict, not a partial seed, and silently promoting it
167
+ * would hand its holders the built-in policy's permissions. Those cases throw.
168
+ */
169
+ /** The built-in role for `name`, reusing a prior (possibly interrupted) seed's row if present. */
170
+ async function ensureBuiltinRole(required) {
171
+ const { deps, workspaceId, name } = required;
172
+ const existing = await deps.repos.roles.findByName({ workspaceId, name });
173
+ if (!existing) {
174
+ const roleId = deps.idGen.newId();
175
+ await deps.repos.roles.save({ id: roleId, workspaceId, name, isBuiltin: true });
176
+ return roleId;
177
+ }
178
+ if (!existing.isBuiltin) {
179
+ throw new Error(`seedIdentity: a non-built-in role named '${name}' already exists in workspace ${workspaceId}. ` +
180
+ `Refusing to adopt it as the built-in '${name}' role — rename or remove it first.`);
181
+ }
182
+ return existing.id;
183
+ }
184
+ /** The built-in policy for `name`, reusing a prior (possibly interrupted) seed's row if present. */
185
+ async function ensureBuiltinPolicy(required) {
186
+ const { deps, workspaceId, name } = required;
187
+ const policyName = `${name}-builtin-policy`;
188
+ const existing = await deps.repos.policies.findByName({ workspaceId, name: policyName });
189
+ if (!existing) {
190
+ const policyId = deps.idGen.newId();
191
+ await deps.repos.policies.save({
192
+ id: policyId,
193
+ workspaceId,
194
+ name: policyName,
195
+ description: `Built-in policy for the seeded '${name}' role.`,
196
+ isBuiltin: true,
197
+ isFrozen: false,
198
+ });
199
+ return policyId;
200
+ }
201
+ if (!existing.isBuiltin) {
202
+ throw new Error(`seedIdentity: a non-built-in policy named '${policyName}' already exists in workspace ${workspaceId}. ` +
203
+ `Refusing to adopt it as the built-in '${name}' policy — rename or remove it first.`);
204
+ }
205
+ return existing.id;
206
+ }
207
+ /**
208
+ * Grant `permissions` to `policyId`, skipping any the policy already holds.
209
+ *
210
+ * Additive by design, and the reason a resumed seed still CONVERGES rather than merely staying
211
+ * quiet: a built-in policy whose permission rows never landed (the interrupt fell between the
212
+ * policy insert and this loop) gains them on the next boot. Compares only unscoped rows because
213
+ * that is the only shape this seed writes — a `resourceType`-scoped row of the same name is a
214
+ * different grant and must not satisfy an unscoped one. A constrained row likewise cannot
215
+ * satisfy this seed because authorization rejects every non-null constraint, including an empty
216
+ * string. Null and absent scope/constraint have the same unconstrained meaning in the evaluator.
217
+ */
218
+ async function ensurePolicyPermissions(required) {
219
+ const { deps, workspaceId, policyId, permissions } = required;
220
+ const existing = await deps.repos.policyPermissions.listByPolicyId({ workspaceId, policyId });
221
+ const held = new Set(existing.filter((row) => row.resourceType == null && row.constraintJson == null).map((row) => row.permission));
222
+ for (const permission of permissions) {
223
+ if (held.has(permission))
224
+ continue;
225
+ await deps.repos.policyPermissions.save({
226
+ id: deps.idGen.newId(),
227
+ workspaceId,
228
+ policyId,
229
+ permission,
230
+ resourceType: null,
231
+ constraintJson: null,
232
+ });
233
+ }
234
+ }
235
+ /** Builds one built-in role + its 1:1 policy + policy_permissions rows, converging on what exists. */
236
+ async function seedBuiltinRoleWithPolicy(required) {
237
+ const { deps, workspaceId, name, permissions, nowIso } = required;
238
+ const roleId = await ensureBuiltinRole({ deps, workspaceId, name });
239
+ const policyId = await ensureBuiltinPolicy({ deps, workspaceId, name });
240
+ // `role_policies` has no unique index, so a replay would otherwise stack a second identical link.
241
+ const links = await deps.repos.rolePolicies.listByRoleId({ workspaceId, roleId });
242
+ if (!links.some((link) => link.policyId === policyId)) {
243
+ await deps.repos.rolePolicies.save({ id: deps.idGen.newId(), workspaceId, roleId, policyId });
244
+ }
245
+ await ensurePolicyPermissions({ deps, workspaceId, policyId, permissions });
246
+ void nowIso; // reserved for a future createdAt column on roles/policies
247
+ return { roleId, policyId };
248
+ }
249
+ /**
250
+ * Repair the one completion step `seedIdentity`'s owner-user guard cannot see.
251
+ *
252
+ * The guard keys on the owner USER, and the owner's `principal_roles` link is written after it. A
253
+ * process that exits between those two adjacent writes leaves a workspace whose owner exists, can
254
+ * still log in, and holds no role — and `resolveEffectivePermissions` (`authorize.ts`) starts from
255
+ * `principal_roles` + `principal_policies`, so with neither present EVERY permission evaluates to
256
+ * `no_grant`. Without this step the guard early-returns on the user it finds forever and the site
257
+ * stays bricked for its owner, silently. Neither host-side reconciler closes it either: the host's
258
+ * `migrateDeprecatedPermissionGrants` and `applyBuiltinRoleGrants` are handed policy and role repos
259
+ * only, never `principalRoles`, so neither can write a principal->role link at all.
260
+ *
261
+ * Why the gate is "holds NO role" rather than "lacks the owner role". Those two conditions differ
262
+ * on exactly one workspace shape, and it matters: an operator who moves the seeded owner principal
263
+ * to a lesser role leaves it lacking the owner link on purpose. Repairing on that broader signal
264
+ * would re-escalate the principal to wildcard authority on the next restart — a privilege
265
+ * escalation shipped as a bug fix. A principal holding no role at all is never a deliberate state;
266
+ * it is only ever the interrupted seed, so that is what this repairs.
267
+ *
268
+ * Converges rather than assuming: it goes through `seedBuiltinRoleWithPolicy`, so a workspace whose
269
+ * owner ROLE or wildcard grant also never landed gains those here instead of being linked to a role
270
+ * that grants nothing. `principal_roles` carries no unique index, hence the read-before-write.
271
+ *
272
+ * @complexity O(1) — a handful of point reads on a healthy boot (one `listByPrincipalId`, which
273
+ * returns early), plus the fixed converge pass for the owner role only when the repair fires.
274
+ */
275
+ async function ensureOwnerRoleBinding(required) {
276
+ const { deps, workspaceId, ownerPrincipalId, nowIso } = required;
277
+ const heldRoles = await deps.repos.principalRoles.listByPrincipalId({
278
+ workspaceId,
279
+ principalId: ownerPrincipalId,
280
+ });
281
+ if (heldRoles.length > 0)
282
+ return;
283
+ const { roleId } = await seedBuiltinRoleWithPolicy({
284
+ deps,
285
+ workspaceId,
286
+ name: OWNER_ROLE_NAME,
287
+ permissions: OWNER_ROLE_PERMISSIONS,
288
+ nowIso,
289
+ });
290
+ await deps.repos.principalRoles.save({
291
+ id: deps.idGen.newId(),
292
+ workspaceId,
293
+ principalId: ownerPrincipalId,
294
+ roleId,
295
+ });
296
+ }
297
+ /**
298
+ * Seed first-boot identity data.
299
+ *
300
+ * Idempotent in two distinct senses, and both are load-bearing:
301
+ * - A seed that RAN TO COMPLETION is a no-op — the owner-username lookup below early-returns after
302
+ * a single confirming read (`ensureOwnerRoleBinding`) and writes nothing. (This path does not
303
+ * reconcile a built-in policy whose permission list has since grown; that is the host's job — see
304
+ * the host's `applyBuiltinRoleGrants` / `migrateDeprecatedPermissionGrants`, chained onto
305
+ * `identityReady`.)
306
+ * - "Ran to completion" is judged on the owner user AND its role link, not the user alone. The link
307
+ * is the seed's last write, so the user is not on its own evidence the seed finished — see
308
+ * `ensureOwnerRoleBinding` for what an interrupt between those two writes costs.
309
+ * - A seed that was INTERRUPTED is resumed, not replayed: every step converges onto the rows a
310
+ * previous attempt already wrote and fills in only what is missing. See the block comment above
311
+ * `ensureBuiltinRole` for why this function must survive being killed halfway.
312
+ *
313
+ * @complexity O(1) — fixed small number of inserts (4 roles/policies, ~2
314
+ * dozen policy_permissions, 3 principals, 1 user, 1 role assignment).
315
+ * @overallScore 100
316
+ */
317
+ export async function seedIdentity(required, optional = {}) {
318
+ const { deps } = required;
319
+ const input = { ...required.input, ...optional };
320
+ const workspaceId = input.workspaceId;
321
+ const ownerUsername = normalizeUsername({ raw: input.ownerUsername });
322
+ const ownerPassword = input.ownerPassword;
323
+ const nowIso = kernelNowIso({ clock: deps.clock });
324
+ const existingOwnerUser = await deps.repos.users.findByUsername({
325
+ workspaceId,
326
+ username: ownerUsername,
327
+ });
328
+ if (existingOwnerUser) {
329
+ // The owner user alone is NOT proof the seed finished — its role link is written after it. See
330
+ // `ensureOwnerRoleBinding`: on a healthy workspace this is one read and no write.
331
+ await ensureOwnerRoleBinding({
332
+ deps,
333
+ workspaceId,
334
+ ownerPrincipalId: existingOwnerUser.principalId,
335
+ nowIso,
336
+ });
337
+ const existingSystem = (await deps.repos.principals.list({ workspaceId })).find((row) => row.kind === "system" && row.id !== LEGACY_USER_LOCAL_PRINCIPAL_ID);
338
+ return {
339
+ ownerPrincipalId: existingOwnerUser.principalId,
340
+ systemPrincipalId: existingSystem?.id ?? LEGACY_USER_LOCAL_PRINCIPAL_ID,
341
+ };
342
+ }
343
+ // Reuse the system principal a prior interrupted seed may already have written: `principals` has
344
+ // no unique index on kind, so a blind re-insert would leave TWO non-legacy system principals and
345
+ // the early-return branch above picking between them by list order.
346
+ const existingSystem = (await deps.repos.principals.list({ workspaceId })).find((row) => row.kind === "system" && row.id !== LEGACY_USER_LOCAL_PRINCIPAL_ID);
347
+ const systemPrincipalId = existingSystem?.id ?? deps.idGen.newId();
348
+ if (!existingSystem) {
349
+ await deps.repos.principals.save({
350
+ id: systemPrincipalId,
351
+ workspaceId,
352
+ kind: "system",
353
+ displayName: "System",
354
+ status: "active",
355
+ createdAt: nowIso,
356
+ });
357
+ }
358
+ // disabled legacy actor so historical `actorId='user-local'`. See docs/decisions/DR-001-identity-and-session-boundary.md.
359
+ // change-sets resolve without rewriting history.
360
+ await deps.repos.principals.save({
361
+ id: LEGACY_USER_LOCAL_PRINCIPAL_ID,
362
+ workspaceId,
363
+ kind: "system",
364
+ displayName: "Legacy actor (pre-identity)",
365
+ status: "disabled",
366
+ disabledAt: nowIso,
367
+ createdAt: nowIso,
368
+ });
369
+ const { roleId: ownerRoleId } = await seedBuiltinRoleWithPolicy({
370
+ deps,
371
+ workspaceId,
372
+ name: OWNER_ROLE_NAME,
373
+ permissions: OWNER_ROLE_PERMISSIONS,
374
+ nowIso,
375
+ });
376
+ await seedBuiltinRoleWithPolicy({
377
+ deps,
378
+ workspaceId,
379
+ name: "admin",
380
+ permissions: BUILTIN_ADMIN_PERMISSIONS,
381
+ nowIso,
382
+ });
383
+ await seedBuiltinRoleWithPolicy({
384
+ deps,
385
+ workspaceId,
386
+ name: "editor",
387
+ permissions: BUILTIN_EDITOR_PERMISSIONS,
388
+ nowIso,
389
+ });
390
+ await seedBuiltinRoleWithPolicy({
391
+ deps,
392
+ workspaceId,
393
+ name: "viewer",
394
+ permissions: BUILTIN_VIEWER_PERMISSIONS,
395
+ nowIso,
396
+ });
397
+ // Hashed BEFORE the owner principal row is written, not inline in the `users.save` below.
398
+ // argon2id is deliberately slow, which made it by far the widest window in this un-awaited,
399
+ // non-transactional sequence for a process to exit between the principal insert and the user
400
+ // insert that gives it meaning — leaving an orphan `kind: "user"` principal behind on every
401
+ // interrupted boot. Hashing first shrinks that window to a single adjacent await.
402
+ const ownerPasswordHash = await deps.hasher.hash({ password: ownerPassword });
403
+ const ownerPrincipalId = deps.idGen.newId();
404
+ await deps.repos.principals.save({
405
+ id: ownerPrincipalId,
406
+ workspaceId,
407
+ kind: "user",
408
+ displayName: "Owner",
409
+ status: "active",
410
+ createdAt: nowIso,
411
+ });
412
+ await deps.repos.users.save({
413
+ principalId: ownerPrincipalId,
414
+ workspaceId,
415
+ username: ownerUsername,
416
+ email: input.ownerEmail,
417
+ passwordHash: ownerPasswordHash,
418
+ });
419
+ // `principal_roles` carries no unique index either — same converge-don't-duplicate rule as the
420
+ // `role_policies` link in `seedBuiltinRoleWithPolicy`.
421
+ const ownerRoleLinks = await deps.repos.principalRoles.listByPrincipalId({
422
+ workspaceId,
423
+ principalId: ownerPrincipalId,
424
+ });
425
+ if (!ownerRoleLinks.some((link) => link.roleId === ownerRoleId)) {
426
+ await deps.repos.principalRoles.save({
427
+ id: deps.idGen.newId(),
428
+ workspaceId,
429
+ principalId: ownerPrincipalId,
430
+ roleId: ownerRoleId,
431
+ });
432
+ }
433
+ return { ownerPrincipalId, systemPrincipalId };
434
+ }
435
+ //# sourceMappingURL=seed.js.map
@@ -0,0 +1,10 @@
1
+ import type { SessionTokenPort } from "../core/runtime-ports.js";
2
+ /** Node adapter retaining the existing 32-byte hex bearer and SHA-256 storage digest. */
3
+ export declare class NodeSessionTokens implements SessionTokenPort {
4
+ constructor(_required: Record<string, never>);
5
+ newToken(_required: Record<string, never>): string;
6
+ hashToken({ rawToken }: {
7
+ rawToken: string;
8
+ }): string;
9
+ }
10
+ //# sourceMappingURL=session-tokens.d.ts.map
@@ -0,0 +1,12 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ /** Node adapter retaining the existing 32-byte hex bearer and SHA-256 storage digest. */
3
+ export class NodeSessionTokens {
4
+ constructor(_required) { }
5
+ newToken(_required) {
6
+ return randomBytes(32).toString("hex");
7
+ }
8
+ hashToken({ rawToken }) {
9
+ return createHash("sha256").update(rawToken).digest("hex");
10
+ }
11
+ }
12
+ //# sourceMappingURL=session-tokens.js.map
@@ -0,0 +1,14 @@
1
+ /**
2
+ * @file Username normalization (behavior.spec §5.1).
3
+ *
4
+ * A `users` row is a duplicate within a workspace iff its `username` equals an
5
+ * existing user's after case-insensitive comparison and Unicode NFC
6
+ * normalization. Normalizing once at every write/lookup site (seed, login,
7
+ * future CREATE_USER) makes that comparison a direct equality check rather
8
+ * than a repeated ad hoc string transform.
9
+ */
10
+ /** NFC-normalize, trim, and lowercase a username for storage/lookup. */
11
+ export declare function normalizeUsername({ raw }: {
12
+ raw: string;
13
+ }): string;
14
+ //# sourceMappingURL=username.d.ts.map
@@ -0,0 +1,14 @@
1
+ /**
2
+ * @file Username normalization (behavior.spec §5.1).
3
+ *
4
+ * A `users` row is a duplicate within a workspace iff its `username` equals an
5
+ * existing user's after case-insensitive comparison and Unicode NFC
6
+ * normalization. Normalizing once at every write/lookup site (seed, login,
7
+ * future CREATE_USER) makes that comparison a direct equality check rather
8
+ * than a repeated ad hoc string transform.
9
+ */
10
+ /** NFC-normalize, trim, and lowercase a username for storage/lookup. */
11
+ export function normalizeUsername({ raw }) {
12
+ return raw.normalize("NFC").trim().toLowerCase();
13
+ }
14
+ //# sourceMappingURL=username.js.map
package/package.json ADDED
@@ -0,0 +1,144 @@
1
+ {
2
+ "name": "@jini-ai/user-management",
3
+ "version": "0.2.0",
4
+ "description": "Runtime-separated user management contracts and Node authentication, authorization, and administration services.",
5
+ "license": "Apache-2.0",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/AINSEP/Jini.git",
9
+ "directory": "packages/user-management"
10
+ },
11
+ "jini": {
12
+ "domain": "capability",
13
+ "kind": "user-management",
14
+ "runtime": "universal",
15
+ "entries": {
16
+ ".": "universal",
17
+ "./server": "node",
18
+ "./react": "browser",
19
+ "./react/testing": "browser",
20
+ "./admin": "universal",
21
+ "./admin/react": "browser",
22
+ "./admin/adapters/http": "universal",
23
+ "./admin/adapters/memory": "universal",
24
+ "./admin/conformance": "universal"
25
+ }
26
+ },
27
+ "type": "module",
28
+ "sideEffects": false,
29
+ "exports": {
30
+ ".": {
31
+ "types": "./dist/core/index.d.ts",
32
+ "import": "./dist/core/index.js",
33
+ "default": "./dist/core/index.js"
34
+ },
35
+ "./server": {
36
+ "types": "./dist/server/index.d.ts",
37
+ "import": "./dist/server/index.js",
38
+ "default": "./dist/server/index.js"
39
+ },
40
+ "./react": {
41
+ "types": "./dist/react/index.d.ts",
42
+ "import": "./dist/react/index.js",
43
+ "default": "./dist/react/index.js"
44
+ },
45
+ "./react/testing": {
46
+ "types": "./dist/react/testing.d.ts",
47
+ "import": "./dist/react/testing.js",
48
+ "default": "./dist/react/testing.js"
49
+ },
50
+ "./admin": {
51
+ "types": "./dist/admin/index.d.ts",
52
+ "import": "./dist/admin/index.js",
53
+ "default": "./dist/admin/index.js"
54
+ },
55
+ "./admin/react": {
56
+ "types": "./dist/admin/react/index.d.ts",
57
+ "import": "./dist/admin/react/index.js",
58
+ "default": "./dist/admin/react/index.js"
59
+ },
60
+ "./admin/adapters/http": {
61
+ "types": "./dist/admin/adapters/http.d.ts",
62
+ "import": "./dist/admin/adapters/http.js",
63
+ "default": "./dist/admin/adapters/http.js"
64
+ },
65
+ "./admin/adapters/memory": {
66
+ "types": "./dist/admin/adapters/memory.d.ts",
67
+ "import": "./dist/admin/adapters/memory.js",
68
+ "default": "./dist/admin/adapters/memory.js"
69
+ },
70
+ "./admin/conformance": {
71
+ "types": "./dist/admin/conformance/index.d.ts",
72
+ "import": "./dist/admin/conformance/index.js",
73
+ "default": "./dist/admin/conformance/index.js"
74
+ }
75
+ },
76
+ "files": [
77
+ "dist",
78
+ "README.md",
79
+ "CHANGELOG.md",
80
+ "LICENSE",
81
+ "!dist/**/__tests__/**",
82
+ "!dist/**/*.test.*",
83
+ "!dist/**/*.map"
84
+ ],
85
+ "publishConfig": {
86
+ "access": "public",
87
+ "registry": "https://registry.npmjs.org"
88
+ },
89
+ "peerDependencies": {
90
+ "argon2": "^0.44.0",
91
+ "react": "^18.3.0 || ^19.0.0",
92
+ "react-dom": "^18.3.0 || ^19.0.0",
93
+ "@jini-ai/ui": "^0.4.0",
94
+ "@jini-ai/admin": "^0.4.0",
95
+ "@jini-ai/agentic": "^0.4.0"
96
+ },
97
+ "peerDependenciesMeta": {
98
+ "argon2": {
99
+ "optional": true
100
+ },
101
+ "react": {
102
+ "optional": true
103
+ },
104
+ "react-dom": {
105
+ "optional": true
106
+ },
107
+ "@jini-ai/ui": {
108
+ "optional": true
109
+ },
110
+ "@jini-ai/admin": {
111
+ "optional": true
112
+ },
113
+ "@jini-ai/agentic": {
114
+ "optional": true
115
+ }
116
+ },
117
+ "devDependencies": {
118
+ "vitest": "^2.1.0",
119
+ "typescript": "^5.6.0",
120
+ "@types/node": "^24.0.0",
121
+ "argon2": "^0.44.0",
122
+ "react": "^19.2.0",
123
+ "react-dom": "^19.2.0",
124
+ "@types/react": "^19.2.0",
125
+ "@types/react-dom": "^19.2.0",
126
+ "@testing-library/react": "^16.3.0",
127
+ "@testing-library/user-event": "^14.5.0",
128
+ "@testing-library/jest-dom": "^6.6.3",
129
+ "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"
138
+ },
139
+ "scripts": {
140
+ "build": "tsc -p tsconfig.json",
141
+ "typecheck": "tsc -p tsconfig.json --noEmit",
142
+ "test": "vitest run"
143
+ }
144
+ }