@jini-ai/user-management 0.3.2 → 0.3.4

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 (68) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/admin/adapters/memory.d.ts +3 -5
  3. package/dist/admin/adapters/memory.js +27 -28
  4. package/dist/admin/conformance/index.js +1 -0
  5. package/dist/admin/controllers/auth.controller.js +8 -6
  6. package/dist/admin/controllers/members.controller.js +9 -6
  7. package/dist/admin/controllers/roles.controller.js +99 -105
  8. package/dist/admin/controllers/users.controller.js +56 -36
  9. package/dist/admin/error-copy.d.ts +11 -0
  10. package/dist/admin/error-copy.js +10 -0
  11. package/dist/admin/people.rules.js +37 -31
  12. package/dist/admin/react/components/MemberRow.d.ts +1 -1
  13. package/dist/admin/react/components/MemberRow.js +12 -2
  14. package/dist/admin/react/hooks/AuthBinding.hooks.d.ts +6 -21
  15. package/dist/admin/react/hooks/AuthBinding.hooks.js +3 -8
  16. package/dist/admin/react/hooks/AuthScope.hooks.d.ts +22 -0
  17. package/dist/admin/react/hooks/AuthScope.hooks.js +10 -0
  18. package/dist/admin/react/hooks/LoginPage.hooks.d.ts +1 -1
  19. package/dist/admin/react/hooks/LoginPage.hooks.js +1 -1
  20. package/dist/admin/react/hooks/MembersBinding.hooks.d.ts +6 -17
  21. package/dist/admin/react/hooks/MembersBinding.hooks.js +3 -8
  22. package/dist/admin/react/hooks/MembersPage.hooks.d.ts +1 -1
  23. package/dist/admin/react/hooks/MembersPage.hooks.js +1 -1
  24. package/dist/admin/react/hooks/MembersScope.hooks.d.ts +17 -0
  25. package/dist/admin/react/hooks/MembersScope.hooks.js +10 -0
  26. package/dist/admin/react/hooks/RolesBinding.hooks.d.ts +7 -21
  27. package/dist/admin/react/hooks/RolesBinding.hooks.js +3 -8
  28. package/dist/admin/react/hooks/RolesController.hooks.js +1 -1
  29. package/dist/admin/react/hooks/RolesPage.hooks.d.ts +2 -2
  30. package/dist/admin/react/hooks/RolesPage.hooks.js +22 -8
  31. package/dist/admin/react/hooks/RolesScope.hooks.d.ts +21 -0
  32. package/dist/admin/react/hooks/RolesScope.hooks.js +10 -0
  33. package/dist/admin/react/hooks/UsersBinding.hooks.d.ts +6 -18
  34. package/dist/admin/react/hooks/UsersBinding.hooks.js +3 -8
  35. package/dist/admin/react/hooks/UsersPage.hooks.d.ts +1 -1
  36. package/dist/admin/react/hooks/UsersPage.hooks.js +1 -1
  37. package/dist/admin/react/hooks/UsersScope.hooks.d.ts +19 -0
  38. package/dist/admin/react/hooks/UsersScope.hooks.js +10 -0
  39. package/dist/admin/react/hooks/UsersTab.hooks.d.ts +6 -0
  40. package/dist/admin/react/hooks/UsersTab.hooks.js +34 -22
  41. package/dist/admin/react/index.d.ts +4 -4
  42. package/dist/admin/react/pages/LoginPage.d.ts +1 -1
  43. package/dist/admin/react/pages/MembersPage.d.ts +1 -1
  44. package/dist/admin/react/pages/RolesPage.d.ts +2 -2
  45. package/dist/admin/react/pages/RolesPage.js +6 -2
  46. package/dist/admin/react/pages/UsersPage.d.ts +2 -2
  47. package/dist/admin/react/pages/UsersPage.js +6 -2
  48. package/dist/admin/rules.js +14 -12
  49. package/dist/react/errors.js +16 -9
  50. package/dist/react/index.d.ts +1 -1
  51. package/dist/react/members/hooks/use-members.hooks.js +21 -12
  52. package/dist/react/testing.js +4 -6
  53. package/dist/react/users/UserRowParts.d.ts +53 -0
  54. package/dist/react/users/UserRowParts.js +39 -0
  55. package/dist/react/users/Users.d.ts +31 -2
  56. package/dist/react/users/Users.js +41 -36
  57. package/dist/react/users/hooks/use-users.hooks.js +57 -30
  58. package/dist/server/admin-crud-service.js +25 -13
  59. package/dist/server/agent-tool-input.js +62 -36
  60. package/dist/server/agent-tool-registrations.d.ts +46 -0
  61. package/dist/server/agent-tool-registrations.js +28 -20
  62. package/dist/server/auth-service.js +35 -20
  63. package/dist/server/authorize.js +7 -9
  64. package/dist/server/permission-migrations.js +35 -25
  65. package/dist/server/repo.memory-transactions.js +5 -6
  66. package/dist/server/repo.memory.js +35 -81
  67. package/dist/server/seed.js +100 -84
  68. package/package.json +9 -9
@@ -1,3 +1,4 @@
1
+ import { matchesRecordFields, upsertRecord } from "@jini-ai/core/records";
1
2
  /**
2
3
  * @file In-memory adapters for the `user-management` repo ports.
3
4
  *
@@ -33,18 +34,13 @@ export class InMemoryPrincipalRepo extends InMemoryIdentityRows {
33
34
  super(_required, { initialRows });
34
35
  }
35
36
  async findById(required) {
36
- return (this.rows.find((row) => row.workspaceId === required.workspaceId && row.id === required.id) ?? null);
37
+ return this.rows.find(record => matchesRecordFields({ record, fields: { workspaceId: required.workspaceId, id: required.id } })) ?? null;
37
38
  }
38
39
  async list(required) {
39
- return this.rows.filter((row) => row.workspaceId === required.workspaceId);
40
+ return this.rows.filter(record => matchesRecordFields({ record, fields: { workspaceId: required.workspaceId } }));
40
41
  }
41
42
  async save(record) {
42
- const index = this.rows.findIndex((row) => row.workspaceId === record.workspaceId && row.id === record.id);
43
- if (index === -1) {
44
- this.rows.push(record);
45
- return;
46
- }
47
- this.rows[index] = record;
43
+ upsertRecord({ records: this.rows, record, identity: { workspaceId: record.workspaceId, id: record.id } });
48
44
  }
49
45
  }
50
46
  export class InMemoryUserRepo extends InMemoryIdentityRows {
@@ -52,21 +48,16 @@ export class InMemoryUserRepo extends InMemoryIdentityRows {
52
48
  super(_required, { initialRows });
53
49
  }
54
50
  async findByPrincipalId(required) {
55
- return (this.rows.find((row) => row.workspaceId === required.workspaceId && row.principalId === required.principalId) ?? null);
51
+ return this.rows.find(record => matchesRecordFields({ record, fields: required })) ?? null;
56
52
  }
57
53
  async findByUsername(required) {
58
- return (this.rows.find((row) => row.workspaceId === required.workspaceId && row.username === required.username) ?? null);
54
+ return this.rows.find(record => matchesRecordFields({ record, fields: required })) ?? null;
59
55
  }
60
56
  async list(required) {
61
- return this.rows.filter((row) => row.workspaceId === required.workspaceId);
57
+ return this.rows.filter(record => matchesRecordFields({ record, fields: { workspaceId: required.workspaceId } }));
62
58
  }
63
59
  async save(record) {
64
- const index = this.rows.findIndex((row) => row.workspaceId === record.workspaceId && row.principalId === record.principalId);
65
- if (index === -1) {
66
- this.rows.push(record);
67
- return;
68
- }
69
- this.rows[index] = record;
60
+ upsertRecord({ records: this.rows, record, identity: { workspaceId: record.workspaceId, principalId: record.principalId } });
70
61
  }
71
62
  }
72
63
  export class InMemorySessionRepo extends InMemoryIdentityRows {
@@ -74,18 +65,13 @@ export class InMemorySessionRepo extends InMemoryIdentityRows {
74
65
  super(_required, { initialRows });
75
66
  }
76
67
  async findById(required) {
77
- return (this.rows.find((row) => row.workspaceId === required.workspaceId && row.id === required.id) ?? null);
68
+ return this.rows.find(record => matchesRecordFields({ record, fields: { workspaceId: required.workspaceId, id: required.id } })) ?? null;
78
69
  }
79
70
  async findByTokenHash(required) {
80
- return (this.rows.find((row) => row.workspaceId === required.workspaceId && row.tokenHash === required.tokenHash) ?? null);
71
+ return this.rows.find(record => matchesRecordFields({ record, fields: required })) ?? null;
81
72
  }
82
73
  async save(record) {
83
- const index = this.rows.findIndex((row) => row.workspaceId === record.workspaceId && row.id === record.id);
84
- if (index === -1) {
85
- this.rows.push(record);
86
- return;
87
- }
88
- this.rows[index] = record;
74
+ upsertRecord({ records: this.rows, record, identity: { workspaceId: record.workspaceId, id: record.id } });
89
75
  }
90
76
  async revoke(required) {
91
77
  const row = this.rows.find((candidate) => candidate.workspaceId === required.workspaceId && candidate.id === required.id);
@@ -93,7 +79,7 @@ export class InMemorySessionRepo extends InMemoryIdentityRows {
93
79
  row.revokedAt = required.revokedAt;
94
80
  }
95
81
  async listByPrincipalId(required) {
96
- return this.rows.filter((row) => row.workspaceId === required.workspaceId && row.principalId === required.principalId);
82
+ return this.rows.filter(record => matchesRecordFields({ record, fields: required }));
97
83
  }
98
84
  }
99
85
  export class InMemoryRoleRepo extends InMemoryIdentityRows {
@@ -101,25 +87,19 @@ export class InMemoryRoleRepo extends InMemoryIdentityRows {
101
87
  super(_required, { initialRows });
102
88
  }
103
89
  async findById(required) {
104
- return (this.rows.find((row) => row.workspaceId === required.workspaceId && row.id === required.id) ?? null);
90
+ return this.rows.find(record => matchesRecordFields({ record, fields: { workspaceId: required.workspaceId, id: required.id } })) ?? null;
105
91
  }
106
92
  async findByName(required) {
107
- return (this.rows.find((row) => row.workspaceId === required.workspaceId && row.name === required.name) ??
108
- null);
93
+ return this.rows.find(record => matchesRecordFields({ record, fields: { workspaceId: required.workspaceId, name: required.name } })) ?? null;
109
94
  }
110
95
  async list(required) {
111
- return this.rows.filter((row) => row.workspaceId === required.workspaceId);
96
+ return this.rows.filter(record => matchesRecordFields({ record, fields: { workspaceId: required.workspaceId } }));
112
97
  }
113
98
  async save(record) {
114
- const index = this.rows.findIndex((row) => row.workspaceId === record.workspaceId && row.id === record.id);
115
- if (index === -1) {
116
- this.rows.push(record);
117
- return;
118
- }
119
- this.rows[index] = record;
99
+ upsertRecord({ records: this.rows, record, identity: { workspaceId: record.workspaceId, id: record.id } });
120
100
  }
121
101
  async delete(required) {
122
- this.rows = this.rows.filter((row) => !(row.workspaceId === required.workspaceId && row.id === required.id));
102
+ this.rows = this.rows.filter(record => !matchesRecordFields({ record, fields: { workspaceId: required.workspaceId, id: required.id } }));
123
103
  }
124
104
  }
125
105
  export class InMemoryPolicyRepo extends InMemoryIdentityRows {
@@ -127,25 +107,19 @@ export class InMemoryPolicyRepo extends InMemoryIdentityRows {
127
107
  super(_required, { initialRows });
128
108
  }
129
109
  async findById(required) {
130
- return (this.rows.find((row) => row.workspaceId === required.workspaceId && row.id === required.id) ?? null);
110
+ return this.rows.find(record => matchesRecordFields({ record, fields: { workspaceId: required.workspaceId, id: required.id } })) ?? null;
131
111
  }
132
112
  async findByName(required) {
133
- return (this.rows.find((row) => row.workspaceId === required.workspaceId && row.name === required.name) ??
134
- null);
113
+ return this.rows.find(record => matchesRecordFields({ record, fields: { workspaceId: required.workspaceId, name: required.name } })) ?? null;
135
114
  }
136
115
  async list(required) {
137
- return this.rows.filter((row) => row.workspaceId === required.workspaceId);
116
+ return this.rows.filter(record => matchesRecordFields({ record, fields: { workspaceId: required.workspaceId } }));
138
117
  }
139
118
  async save(record) {
140
- const index = this.rows.findIndex((row) => row.workspaceId === record.workspaceId && row.id === record.id);
141
- if (index === -1) {
142
- this.rows.push(record);
143
- return;
144
- }
145
- this.rows[index] = record;
119
+ upsertRecord({ records: this.rows, record, identity: { workspaceId: record.workspaceId, id: record.id } });
146
120
  }
147
121
  async delete(required) {
148
- this.rows = this.rows.filter((row) => !(row.workspaceId === required.workspaceId && row.id === required.id));
122
+ this.rows = this.rows.filter(record => !matchesRecordFields({ record, fields: { workspaceId: required.workspaceId, id: required.id } }));
149
123
  }
150
124
  }
151
125
  export class InMemoryPolicyPermissionRepo extends InMemoryIdentityRows {
@@ -153,21 +127,16 @@ export class InMemoryPolicyPermissionRepo extends InMemoryIdentityRows {
153
127
  super(_required, { initialRows });
154
128
  }
155
129
  async listByPolicyId(required) {
156
- return this.rows.filter((row) => row.workspaceId === required.workspaceId && row.policyId === required.policyId);
130
+ return this.rows.filter(record => matchesRecordFields({ record, fields: required }));
157
131
  }
158
132
  async save(record) {
159
- const index = this.rows.findIndex((row) => row.workspaceId === record.workspaceId && row.id === record.id);
160
- if (index === -1) {
161
- this.rows.push(record);
162
- return;
163
- }
164
- this.rows[index] = record;
133
+ upsertRecord({ records: this.rows, record, identity: { workspaceId: record.workspaceId, id: record.id } });
165
134
  }
166
135
  async deleteByPolicyId(required) {
167
- this.rows = this.rows.filter((row) => !(row.workspaceId === required.workspaceId && row.policyId === required.policyId));
136
+ this.rows = this.rows.filter(record => !matchesRecordFields({ record, fields: { workspaceId: required.workspaceId, policyId: required.policyId } }));
168
137
  }
169
138
  async delete(required) {
170
- this.rows = this.rows.filter((row) => !(row.workspaceId === required.workspaceId && row.id === required.id));
139
+ this.rows = this.rows.filter(record => !matchesRecordFields({ record, fields: { workspaceId: required.workspaceId, id: required.id } }));
171
140
  }
172
141
  }
173
142
  export class InMemoryRolePolicyRepo extends InMemoryIdentityRows {
@@ -175,18 +144,13 @@ export class InMemoryRolePolicyRepo extends InMemoryIdentityRows {
175
144
  super(_required, { initialRows });
176
145
  }
177
146
  async listByRoleId(required) {
178
- return this.rows.filter((row) => row.workspaceId === required.workspaceId && row.roleId === required.roleId);
147
+ return this.rows.filter(record => matchesRecordFields({ record, fields: { workspaceId: required.workspaceId, roleId: required.roleId } }));
179
148
  }
180
149
  async listByPolicyId(required) {
181
- return this.rows.filter((row) => row.workspaceId === required.workspaceId && row.policyId === required.policyId);
150
+ return this.rows.filter(record => matchesRecordFields({ record, fields: { workspaceId: required.workspaceId, policyId: required.policyId } }));
182
151
  }
183
152
  async save(record) {
184
- const index = this.rows.findIndex((row) => row.workspaceId === record.workspaceId && row.id === record.id);
185
- if (index === -1) {
186
- this.rows.push(record);
187
- return;
188
- }
189
- this.rows[index] = record;
153
+ upsertRecord({ records: this.rows, record, identity: { workspaceId: record.workspaceId, id: record.id } });
190
154
  }
191
155
  }
192
156
  export class InMemoryPrincipalRoleRepo extends InMemoryIdentityRows {
@@ -194,18 +158,13 @@ export class InMemoryPrincipalRoleRepo extends InMemoryIdentityRows {
194
158
  super(_required, { initialRows });
195
159
  }
196
160
  async listByPrincipalId(required) {
197
- return this.rows.filter((row) => row.workspaceId === required.workspaceId && row.principalId === required.principalId);
161
+ return this.rows.filter(record => matchesRecordFields({ record, fields: required }));
198
162
  }
199
163
  async listByRoleId(required) {
200
- return this.rows.filter((row) => row.workspaceId === required.workspaceId && row.roleId === required.roleId);
164
+ return this.rows.filter(record => matchesRecordFields({ record, fields: { workspaceId: required.workspaceId, roleId: required.roleId } }));
201
165
  }
202
166
  async save(record) {
203
- const index = this.rows.findIndex((row) => row.workspaceId === record.workspaceId && row.id === record.id);
204
- if (index === -1) {
205
- this.rows.push(record);
206
- return;
207
- }
208
- this.rows[index] = record;
167
+ upsertRecord({ records: this.rows, record, identity: { workspaceId: record.workspaceId, id: record.id } });
209
168
  }
210
169
  }
211
170
  export class InMemoryPrincipalPolicyRepo extends InMemoryIdentityRows {
@@ -213,18 +172,13 @@ export class InMemoryPrincipalPolicyRepo extends InMemoryIdentityRows {
213
172
  super(_required, { initialRows });
214
173
  }
215
174
  async listByPrincipalId(required) {
216
- return this.rows.filter((row) => row.workspaceId === required.workspaceId && row.principalId === required.principalId);
175
+ return this.rows.filter(record => matchesRecordFields({ record, fields: required }));
217
176
  }
218
177
  async listByPolicyId(required) {
219
- return this.rows.filter((row) => row.workspaceId === required.workspaceId && row.policyId === required.policyId);
178
+ return this.rows.filter(record => matchesRecordFields({ record, fields: required }));
220
179
  }
221
180
  async save(record) {
222
- const index = this.rows.findIndex((row) => row.workspaceId === record.workspaceId && row.id === record.id);
223
- if (index === -1) {
224
- this.rows.push(record);
225
- return;
226
- }
227
- this.rows[index] = record;
181
+ upsertRecord({ records: this.rows, record, identity: { workspaceId: record.workspaceId, id: record.id } });
228
182
  }
229
183
  }
230
184
  //# sourceMappingURL=repo.memory.js.map
@@ -240,7 +240,7 @@ async function ensurePolicyPermissions(required) {
240
240
  }
241
241
  /** Builds one built-in role + its 1:1 policy + policy_permissions rows, converging on what exists. */
242
242
  async function seedBuiltinRoleWithPolicy(required) {
243
- const { deps, workspaceId, name, permissions, nowIso } = required;
243
+ const { deps, workspaceId, name, permissions } = required;
244
244
  const roleId = await ensureBuiltinRole({ deps, workspaceId, name });
245
245
  const policyId = await ensureBuiltinPolicy({ deps, workspaceId, name });
246
246
  // `role_policies` has no unique index, so a replay would otherwise stack a second identical link.
@@ -249,7 +249,6 @@ async function seedBuiltinRoleWithPolicy(required) {
249
249
  await deps.repos.rolePolicies.save({ id: deps.idGen.newId(), workspaceId, roleId, policyId });
250
250
  }
251
251
  await ensurePolicyPermissions({ deps, workspaceId, policyId, permissions });
252
- void nowIso; // reserved for a future createdAt column on roles/policies
253
252
  return { roleId, policyId };
254
253
  }
255
254
  /**
@@ -279,7 +278,7 @@ async function seedBuiltinRoleWithPolicy(required) {
279
278
  * returns early), plus the fixed converge pass for the owner role only when the repair fires.
280
279
  */
281
280
  async function ensureOwnerRoleBinding(required) {
282
- const { deps, workspaceId, ownerPrincipalId, nowIso } = required;
281
+ const { deps, workspaceId, ownerPrincipalId } = required;
283
282
  const heldRoles = await deps.repos.principalRoles.listByPrincipalId({
284
283
  workspaceId,
285
284
  principalId: ownerPrincipalId,
@@ -291,7 +290,6 @@ async function ensureOwnerRoleBinding(required) {
291
290
  workspaceId,
292
291
  name: OWNER_ROLE_NAME,
293
292
  permissions: OWNER_ROLE_PERMISSIONS,
294
- nowIso,
295
293
  });
296
294
  await deps.repos.principalRoles.save({
297
295
  id: deps.idGen.newId(),
@@ -300,64 +298,41 @@ async function ensureOwnerRoleBinding(required) {
300
298
  roleId,
301
299
  });
302
300
  }
301
+ /** The seeded built-in roles below the owner, in seed order, each with its permission list. */
302
+ const BUILTIN_NON_OWNER_ROLES = [
303
+ { name: "admin", permissions: BUILTIN_ADMIN_PERMISSIONS },
304
+ { name: "editor", permissions: BUILTIN_EDITOR_PERMISSIONS },
305
+ { name: "viewer", permissions: BUILTIN_VIEWER_PERMISSIONS },
306
+ ];
307
+ /** The workspace's non-legacy `system` principal, if one exists (first in roster order). */
308
+ async function findSystemPrincipal(required) {
309
+ const { deps, workspaceId } = required;
310
+ return (await deps.repos.principals.list({ workspaceId })).find((row) => row.kind === "system" && row.id !== LEGACY_USER_LOCAL_PRINCIPAL_ID);
311
+ }
303
312
  /**
304
- * Seed first-boot identity data.
313
+ * The owner user a previous seed already wrote, or `null` on a first (or interrupted-before-owner) seed.
305
314
  *
306
- * Idempotent in two distinct senses, and both are load-bearing:
307
- * - A seed that RAN TO COMPLETION is a no-op — the owner lookup below early-returns after
308
- * confirming its role binding (`ensureOwnerRoleBinding`) and writes nothing. (This path does not
309
- * reconcile a built-in policy whose permission list has since grown; that is the host's job — see
310
- * the host's `applyBuiltinRoleGrants` / `migrateDeprecatedPermissionGrants`, chained onto
311
- * `identityReady`.)
312
- * - "Ran to completion" is judged on the owner user AND its role link, not the user alone. The link
313
- * is the seed's last write, so the user is not on its own evidence the seed finished — see
314
- * `ensureOwnerRoleBinding` for what an interrupt between those two writes costs.
315
- * - A seed that was INTERRUPTED is resumed, not replayed: every step converges onto the rows a
316
- * previous attempt already wrote and fills in only what is missing. See the block comment above
317
- * `ensureBuiltinRole` for why this function must survive being killed halfway.
318
- *
319
- * @complexity O(1) owner lookup plus the existing principal roster read; fresh seeds make a
320
- * fixed small number of inserts (4 roles/policies, ~2 dozen policy_permissions,
321
- * 3 principals, 1 user, 1 role assignment).
322
- * @overallScore 100
315
+ * An explicit binding is authoritative, even when somebody has reused its former login name.
316
+ * A missing bound row is an error, never permission to mint a replacement owner.
323
317
  */
324
- export async function seedIdentity(required, optional = {}) {
325
- const { deps } = required;
326
- const input = { ...required.input, ...optional };
327
- const workspaceId = input.workspaceId;
328
- const ownerUsername = normalizeUsername({ raw: input.ownerUsername });
329
- const ownerPassword = input.ownerPassword;
330
- const nowIso = kernelNowIso({ clock: deps.clock });
331
- // An explicit binding is authoritative, even when somebody has reused its former login name.
332
- // A missing bound row is an error, never permission to mint a replacement owner.
333
- const seededOwnerUser = input.seededOwnerPrincipalId !== undefined
334
- ? await deps.repos.users.findByPrincipalId({ workspaceId, principalId: input.seededOwnerPrincipalId }) : null;
335
- if (input.seededOwnerPrincipalId !== undefined && !seededOwnerUser) {
336
- throw new IdentityNotFoundError({ message: "the bound seeded owner user was not found; restore the owner identity before booting" });
318
+ async function findExistingOwnerUser(required) {
319
+ const { deps, workspaceId, ownerUsername, seededOwnerPrincipalId } = required;
320
+ if (seededOwnerPrincipalId === undefined) {
321
+ return deps.repos.users.findByUsername({ workspaceId, username: ownerUsername });
337
322
  }
338
- const existingOwnerUser = seededOwnerUser ?? await deps.repos.users.findByUsername({
339
- workspaceId,
340
- username: ownerUsername,
341
- });
342
- if (existingOwnerUser) {
343
- // The owner user alone is NOT proof the seed finished — its role link is written after it. See
344
- // `ensureOwnerRoleBinding`: on a healthy workspace this is one read and no write.
345
- await ensureOwnerRoleBinding({
346
- deps,
347
- workspaceId,
348
- ownerPrincipalId: existingOwnerUser.principalId,
349
- nowIso,
350
- });
351
- const existingSystem = (await deps.repos.principals.list({ workspaceId })).find((row) => row.kind === "system" && row.id !== LEGACY_USER_LOCAL_PRINCIPAL_ID);
352
- return {
353
- ownerPrincipalId: existingOwnerUser.principalId,
354
- systemPrincipalId: existingSystem?.id ?? LEGACY_USER_LOCAL_PRINCIPAL_ID,
355
- };
323
+ const seededOwnerUser = await deps.repos.users.findByPrincipalId({ workspaceId, principalId: seededOwnerPrincipalId });
324
+ if (!seededOwnerUser) {
325
+ throw new IdentityNotFoundError({ message: "the bound seeded owner user was not found; restore the owner identity before booting" });
356
326
  }
327
+ return seededOwnerUser;
328
+ }
329
+ /** Write the `system` principal (reusing an interrupted seed's) and the disabled legacy actor; returns the system id. */
330
+ async function ensureSystemPrincipals(required) {
331
+ const { deps, workspaceId, nowIso } = required;
357
332
  // Reuse the system principal a prior interrupted seed may already have written: `principals` has
358
333
  // no unique index on kind, so a blind re-insert would leave TWO non-legacy system principals and
359
- // the early-return branch above picking between them by list order.
360
- const existingSystem = (await deps.repos.principals.list({ workspaceId })).find((row) => row.kind === "system" && row.id !== LEGACY_USER_LOCAL_PRINCIPAL_ID);
334
+ // `seedIdentity`'s early-return branch picking between them by list order.
335
+ const existingSystem = await findSystemPrincipal({ deps, workspaceId });
361
336
  const systemPrincipalId = existingSystem?.id ?? deps.idGen.newId();
362
337
  if (!existingSystem) {
363
338
  await deps.repos.principals.save({
@@ -380,34 +355,11 @@ export async function seedIdentity(required, optional = {}) {
380
355
  disabledAt: nowIso,
381
356
  createdAt: nowIso,
382
357
  });
383
- const { roleId: ownerRoleId } = await seedBuiltinRoleWithPolicy({
384
- deps,
385
- workspaceId,
386
- name: OWNER_ROLE_NAME,
387
- permissions: OWNER_ROLE_PERMISSIONS,
388
- nowIso,
389
- });
390
- await seedBuiltinRoleWithPolicy({
391
- deps,
392
- workspaceId,
393
- name: "admin",
394
- permissions: BUILTIN_ADMIN_PERMISSIONS,
395
- nowIso,
396
- });
397
- await seedBuiltinRoleWithPolicy({
398
- deps,
399
- workspaceId,
400
- name: "editor",
401
- permissions: BUILTIN_EDITOR_PERMISSIONS,
402
- nowIso,
403
- });
404
- await seedBuiltinRoleWithPolicy({
405
- deps,
406
- workspaceId,
407
- name: "viewer",
408
- permissions: BUILTIN_VIEWER_PERMISSIONS,
409
- nowIso,
410
- });
358
+ return systemPrincipalId;
359
+ }
360
+ /** Write the owner principal, its user row, and its owner-role link — the seed's final writes, in that order. */
361
+ async function createOwner(required) {
362
+ const { deps, workspaceId, ownerUsername, ownerPassword, ownerEmail, ownerRoleId, nowIso } = required;
411
363
  // Hashed BEFORE the owner principal row is written, not inline in the `users.save` below.
412
364
  // argon2id is deliberately slow, which made it by far the widest window in this un-awaited,
413
365
  // non-transactional sequence for a process to exit between the principal insert and the user
@@ -427,7 +379,7 @@ export async function seedIdentity(required, optional = {}) {
427
379
  principalId: ownerPrincipalId,
428
380
  workspaceId,
429
381
  username: ownerUsername,
430
- email: input.ownerEmail,
382
+ email: ownerEmail,
431
383
  passwordHash: ownerPasswordHash,
432
384
  });
433
385
  // `principal_roles` carries no unique index either — same converge-don't-duplicate rule as the
@@ -444,6 +396,70 @@ export async function seedIdentity(required, optional = {}) {
444
396
  roleId: ownerRoleId,
445
397
  });
446
398
  }
399
+ return ownerPrincipalId;
400
+ }
401
+ /**
402
+ * Seed first-boot identity data.
403
+ *
404
+ * Idempotent in two distinct senses, and both are load-bearing:
405
+ * - A seed that RAN TO COMPLETION is a no-op — the owner lookup below early-returns after
406
+ * confirming its role binding (`ensureOwnerRoleBinding`) and writes nothing. (This path does not
407
+ * reconcile a built-in policy whose permission list has since grown; that is the host's job — see
408
+ * the host's `applyBuiltinRoleGrants` / `migrateDeprecatedPermissionGrants`, chained onto
409
+ * `identityReady`.)
410
+ * - "Ran to completion" is judged on the owner user AND its role link, not the user alone. The link
411
+ * is the seed's last write, so the user is not on its own evidence the seed finished — see
412
+ * `ensureOwnerRoleBinding` for what an interrupt between those two writes costs.
413
+ * - A seed that was INTERRUPTED is resumed, not replayed: every step converges onto the rows a
414
+ * previous attempt already wrote and fills in only what is missing. See the block comment above
415
+ * `ensureBuiltinRole` for why this function must survive being killed halfway.
416
+ *
417
+ * @complexity O(1) owner lookup plus the existing principal roster read; fresh seeds make a
418
+ * fixed small number of inserts (4 roles/policies, ~2 dozen policy_permissions,
419
+ * 3 principals, 1 user, 1 role assignment).
420
+ * @overallScore 100
421
+ */
422
+ export async function seedIdentity(required, optional = {}) {
423
+ const { deps } = required;
424
+ const input = { ...required.input, ...optional };
425
+ const workspaceId = input.workspaceId;
426
+ const ownerUsername = normalizeUsername({ raw: input.ownerUsername });
427
+ const nowIso = kernelNowIso({ clock: deps.clock });
428
+ const existingOwnerUser = await findExistingOwnerUser({
429
+ deps,
430
+ workspaceId,
431
+ ownerUsername,
432
+ seededOwnerPrincipalId: input.seededOwnerPrincipalId,
433
+ });
434
+ if (existingOwnerUser) {
435
+ // The owner user alone is NOT proof the seed finished — its role link is written after it. See
436
+ // `ensureOwnerRoleBinding`: on a healthy workspace this is one read and no write.
437
+ await ensureOwnerRoleBinding({ deps, workspaceId, ownerPrincipalId: existingOwnerUser.principalId });
438
+ const existingSystem = await findSystemPrincipal({ deps, workspaceId });
439
+ return {
440
+ ownerPrincipalId: existingOwnerUser.principalId,
441
+ systemPrincipalId: existingSystem?.id ?? LEGACY_USER_LOCAL_PRINCIPAL_ID,
442
+ };
443
+ }
444
+ const systemPrincipalId = await ensureSystemPrincipals({ deps, workspaceId, nowIso });
445
+ const { roleId: ownerRoleId } = await seedBuiltinRoleWithPolicy({
446
+ deps,
447
+ workspaceId,
448
+ name: OWNER_ROLE_NAME,
449
+ permissions: OWNER_ROLE_PERMISSIONS,
450
+ });
451
+ for (const role of BUILTIN_NON_OWNER_ROLES) {
452
+ await seedBuiltinRoleWithPolicy({ deps, workspaceId, name: role.name, permissions: role.permissions });
453
+ }
454
+ const ownerPrincipalId = await createOwner({
455
+ deps,
456
+ workspaceId,
457
+ ownerUsername,
458
+ ownerPassword: input.ownerPassword,
459
+ ownerEmail: input.ownerEmail,
460
+ ownerRoleId,
461
+ nowIso,
462
+ });
447
463
  return { ownerPrincipalId, systemPrincipalId };
448
464
  }
449
465
  //# sourceMappingURL=seed.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jini-ai/user-management",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "Runtime-separated user management contracts and Node authentication, authorization, and administration services.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -87,13 +87,13 @@
87
87
  "registry": "https://registry.npmjs.org"
88
88
  },
89
89
  "dependencies": {
90
- "@jini-ai/core": "^0.4.3"
90
+ "@jini-ai/core": "^0.4.9"
91
91
  },
92
92
  "peerDependencies": {
93
93
  "argon2": "^0.44.0",
94
94
  "react": "^18.3.0 || ^19.0.0",
95
95
  "react-dom": "^18.3.0 || ^19.0.0",
96
- "@jini-ai/ui": "^0.4.8",
96
+ "@jini-ai/ui": "^0.4.8 || ^0.5.0",
97
97
  "@jini-ai/admin": "^0.4.6",
98
98
  "@jini-ai/agentic": "^0.4.4",
99
99
  "@jini-ai/ui-kit": "^0.1.3"
@@ -134,12 +134,12 @@
134
134
  "@testing-library/user-event": "^14.5.0",
135
135
  "@testing-library/jest-dom": "^6.6.3",
136
136
  "jsdom": "^25.0.1",
137
- "@jini-ai/ui": "0.4.8",
138
- "@jini-ai/core": "^0.4.3",
139
- "@jini-ai/agentic": "0.4.4",
140
- "@jini-ai/admin": "0.4.6",
141
- "@jini-ai/ui-kit": "^0.1.3",
142
- "@jini-ai/cms": "0.5.6"
137
+ "@jini-ai/ui": "0.5.6",
138
+ "@jini-ai/agentic": "0.4.5",
139
+ "@jini-ai/core": "^0.4.9",
140
+ "@jini-ai/ui-kit": "^0.1.5",
141
+ "@jini-ai/admin": "0.4.13",
142
+ "@jini-ai/cms": "0.5.14"
143
143
  },
144
144
  "scripts": {
145
145
  "build": "tsc -p tsconfig.json",