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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +2 -0
  2. package/dist/admin/adapters/http.js +15 -5
  3. package/dist/admin/adapters/memory.js +103 -38
  4. package/dist/admin/conformance/index.js +48 -30
  5. package/dist/admin/controllers/controller-store.js +18 -6
  6. package/dist/admin/controllers/members.controller.js +48 -21
  7. package/dist/admin/controllers/roles.controller.js +98 -49
  8. package/dist/admin/controllers/users.controller.js +22 -11
  9. package/dist/admin/error-copy.js +3 -1
  10. package/dist/admin/ports.d.ts +11 -10
  11. package/dist/admin/react/components/PermissionPanel.js +3 -2
  12. package/dist/admin/react/components/PolicyRow.js +3 -2
  13. package/dist/admin/react/components/RoleRow.js +3 -2
  14. package/dist/admin/react/components/UsersConfirmation.js +1 -2
  15. package/dist/admin/react/hooks/AuthScope.hooks.js +2 -1
  16. package/dist/admin/react/hooks/MembersPage.hooks.js +23 -10
  17. package/dist/admin/react/hooks/MembersScope.hooks.js +2 -1
  18. package/dist/admin/react/hooks/UsersPage.hooks.js +13 -5
  19. package/dist/admin/react/hooks/UsersScope.hooks.js +2 -1
  20. package/dist/admin/react/index.d.ts +6 -6
  21. package/dist/admin/rules.js +15 -7
  22. package/dist/core/ports.d.ts +6 -0
  23. package/dist/react/errors.js +3 -1
  24. package/dist/react/users/Users.js +2 -1
  25. package/dist/react/users/rules.js +9 -5
  26. package/dist/server/admin-crud-service.js +17 -13
  27. package/dist/server/agent-tool-registrations.js +18 -15
  28. package/dist/server/agent-tools.js +38 -31
  29. package/dist/server/auth-challenge.js +4 -3
  30. package/dist/server/hasher.js +2 -1
  31. package/dist/server/repo.memory-transactions.js +13 -9
  32. package/dist/server/repo.memory.d.ts +3 -0
  33. package/dist/server/repo.memory.js +8 -0
  34. package/dist/server/seed.d.ts +17 -1
  35. package/dist/server/seed.js +25 -2
  36. package/package.json +41 -32
package/README.md CHANGED
@@ -21,6 +21,8 @@ formats. Native Argon2 is optional; construct its adapter with
21
21
  `new Argon2PasswordHasher({ loadBinding: loadArgon2Binding }, { memoryCost: 19456 })`.
22
22
  Server entry imports never pull in React or a native binding eagerly.
23
23
 
24
+ `ensurePrincipal({ principalRepo, externalId, displayName }, { workspaceId, clock })` from the server entry ensures only a credential-free user principal (no roles/policies), using the trimmed external ID as its principal ID; scope defaults to `"local"`, inputs allow 1–255 trimmed characters, and existing rows remain unchanged. It requires the optional atomic `PrincipalRepoPort.insertIfAbsent` capability (otherwise it fails closed); durable adapters must enforce composite-key uniqueness across processes. `InMemoryPrincipalRepo` implements it for tests/dev only.
25
+
24
26
  React consumers install compatible React/React DOM and `@jini-ai/{ui,admin,agentic}`
25
27
  peers. These peers are optional for universal/server consumers. Mount Users
26
28
  inside `FetchQueryProvider` from `@jini-ai/ui/panel-kit`; the current provider owns
@@ -52,18 +52,28 @@ export function createHttpMembersApi({ transport, workspaceId }, { basePath = '/
52
52
  return transport.request({ path, method, ...(body ? { body } : {}) }, o.signal ? { signal: o.signal } : {});
53
53
  };
54
54
  return {
55
- listMembers: (_r, o = {}) => { const query = new URLSearchParams(); if (o.afterId !== undefined)
56
- query.set('afterId', o.afterId); if (o.limit !== undefined)
57
- query.set('limit', String(o.limit)); return request(`${root}${query.size ? `?${query}` : ''}`, 'GET', o); },
55
+ listMembers: (_r, o = {}) => {
56
+ const query = new URLSearchParams();
57
+ if (o.afterId !== undefined) {
58
+ query.set('afterId', o.afterId);
59
+ }
60
+ if (o.limit !== undefined) {
61
+ query.set('limit', String(o.limit));
62
+ }
63
+ const suffix = query.size ? `?${query}` : '';
64
+ return request(`${root}${suffix}`, 'GET', o);
65
+ },
58
66
  getMember: (r, o = {}) => request(`${root}/${encodeURIComponent(r.id)}`, 'GET', o),
59
67
  disableMember: (r, o = {}) => request(`${root}/${encodeURIComponent(r.id)}/disable`, 'POST', o),
60
68
  requestMemberMagicLink: (r, o = {}) => request(`${root}/request-magic-link`, 'POST', o, { email: r.email, ...(o.redirectPath !== undefined ? { redirectPath: o.redirectPath } : {}) }),
61
69
  };
62
70
  }
63
71
  export function createHttpAuthApi({ transport }, { authPath = '/auth' } = {}) {
64
- return { login: async (r, o = {}) => {
72
+ return {
73
+ login: async (r, o = {}) => {
65
74
  o.signal?.throwIfAborted();
66
75
  return transport.request({ path: `${authPath.replace(/\/$/, '')}/login`, method: 'POST', body: { username: r.username, password: r.password } }, o.signal ? { signal: o.signal } : {});
67
- } };
76
+ }
77
+ };
68
78
  }
69
79
  //# sourceMappingURL=http.js.map
@@ -9,7 +9,10 @@ function inWorkspace(records, workspaceId) {
9
9
  function check(o) { o.signal?.throwIfAborted(); }
10
10
  /** Unknown ids deliberately fail closed: they are neither owner nor operator. */
11
11
  function ownership({ known, owners, principalId }) {
12
- return known.has(principalId) ? owners.has(principalId) ? 'owner' : 'operator' : 'unknown';
12
+ if (!known.has(principalId)) {
13
+ return 'unknown';
14
+ }
15
+ return owners.has(principalId) ? 'owner' : 'operator';
13
16
  }
14
17
  /** Isolated fake of the server wire contract, including immutable and issuer guards.
15
18
  * It is not a production authorizer or a persistent identity repository. */
@@ -22,38 +25,44 @@ export function createMemoryRolesApi({ workspaceId }, options = {}) {
22
25
  const id = () => {
23
26
  let next;
24
27
  do {
25
- next = `memory-${++sequence}`;
28
+ ++sequence;
29
+ next = `memory-${sequence}`;
26
30
  } while (roles.has(next) || policies.has(next) || permissions.has(next));
27
31
  return next;
28
32
  };
29
33
  const fail = (code, message) => { throw new RolesApiError({ code, message }); };
30
34
  const write = (o) => {
31
35
  check(o);
32
- if (!canManageRoles({ permissions: grants }))
36
+ if (!canManageRoles({ permissions: grants })) {
33
37
  fail('FORBIDDEN', 'Missing role.manage.');
38
+ }
34
39
  };
35
40
  const name = (v) => {
36
41
  const n = v.trim().normalize('NFC');
37
- if (!n)
42
+ if (!n) {
38
43
  fail('VALIDATION_ERROR', 'Name is required.');
44
+ }
39
45
  return n;
40
46
  };
41
47
  const unique = (map, n, except) => {
42
- if ([...map.values()].some(r => r.id !== except && r.name.toLowerCase() === n.toLowerCase()))
48
+ if ([...map.values()].some(r => r.id !== except && r.name.toLowerCase() === n.toLowerCase())) {
43
49
  fail('RESOURCE_CONFLICT', 'Name already exists.');
50
+ }
44
51
  };
45
52
  const getRole = (roleId) => roles.get(roleId) ?? fail('RESOURCE_NOT_FOUND', 'Role not found.');
46
53
  const getPolicy = (policyId) => policies.get(policyId) ?? fail('RESOURCE_NOT_FOUND', 'Policy not found.');
47
54
  const editableRole = (roleId) => {
48
55
  const r = getRole(roleId);
49
- if (r.isBuiltin)
56
+ if (r.isBuiltin) {
50
57
  fail('VALIDATION_ERROR', 'Built-in role is immutable.');
58
+ }
51
59
  return r;
52
60
  };
53
61
  const editablePolicy = (policyId) => {
54
62
  const p = getPolicy(policyId);
55
- if (p.isBuiltin || p.isFrozen)
63
+ if (p.isBuiltin || p.isFrozen) {
56
64
  fail('VALIDATION_ERROR', 'Built-in or frozen policy is immutable.');
65
+ }
57
66
  return p;
58
67
  };
59
68
  return {
@@ -64,16 +73,18 @@ export function createMemoryRolesApi({ workspaceId }, options = {}) {
64
73
  async deleteRole(r, o = {}) {
65
74
  write(o);
66
75
  editableRole(r.roleId);
67
- if (options.referencedRoleIds?.includes(r.roleId))
76
+ if (options.referencedRoleIds?.includes(r.roleId)) {
68
77
  fail('RESOURCE_CONFLICT', 'Role is still assigned.');
78
+ }
69
79
  roles.delete(r.roleId);
70
80
  },
71
81
  async createPolicy(r, o = {}) { write(o); const n = name(r.name); unique(policies, n); const policy = { id: id(), workspaceId, name: n, isBuiltin: false, isFrozen: false, ...(o.description !== undefined ? { description: o.description } : {}) }; policies.set(policy.id, policy); return { policy: { ...policy } }; },
72
82
  async updatePolicy(r, o = {}) {
73
83
  write(o);
74
84
  const old = editablePolicy(r.policyId);
75
- if (o.name === undefined && o.description === undefined)
85
+ if (o.name === undefined && o.description === undefined) {
76
86
  fail('VALIDATION_ERROR', 'At least one of name or description is required.');
87
+ }
77
88
  const n = o.name === undefined ? old.name : name(o.name);
78
89
  unique(policies, n, old.id);
79
90
  const policy = { ...old, name: n, ...(o.description !== undefined ? { description: o.description } : {}) };
@@ -83,21 +94,26 @@ export function createMemoryRolesApi({ workspaceId }, options = {}) {
83
94
  async deletePolicy(r, o = {}) {
84
95
  write(o);
85
96
  editablePolicy(r.policyId);
86
- if (options.referencedPolicyIds?.includes(r.policyId))
97
+ if (options.referencedPolicyIds?.includes(r.policyId)) {
87
98
  fail('RESOURCE_CONFLICT', 'Policy is still attached.');
99
+ }
88
100
  policies.delete(r.policyId);
89
- for (const p of permissions.values())
90
- if (p.policyId === r.policyId)
101
+ for (const p of permissions.values()) {
102
+ if (p.policyId === r.policyId) {
91
103
  permissions.delete(p.id);
104
+ }
105
+ }
92
106
  },
93
107
  async listPolicyPermissions(r, o = {}) { check(o); getPolicy(r.policyId); return { policyPermissions: [...permissions.values()].filter(p => p.policyId === r.policyId).map(p => ({ ...p })) }; },
94
108
  async writePolicyPermission(r, o = {}) {
95
109
  write(o);
96
110
  editablePolicy(r.policyId);
97
- if (!catalog.has(r.permission))
111
+ if (!catalog.has(r.permission)) {
98
112
  fail('PERMISSION_UNKNOWN', 'Unrecognized permission.');
99
- if (!hasAdminGrant({ permissions: grants, permission: r.permission }))
113
+ }
114
+ if (!hasAdminGrant({ permissions: grants, permission: r.permission })) {
100
115
  fail('GRANT_EXCEEDS_ISSUER', 'Grant exceeds issuer.');
116
+ }
101
117
  const resourceType = o.resourceType || null;
102
118
  const policyPermission = { id: id(), workspaceId, policyId: r.policyId, permission: r.permission, resourceType, constraintJson: null };
103
119
  permissions.set(policyPermission.id, policyPermission);
@@ -107,8 +123,9 @@ export function createMemoryRolesApi({ workspaceId }, options = {}) {
107
123
  write(o);
108
124
  editablePolicy(r.policyId);
109
125
  const p = permissions.get(r.policyPermissionId);
110
- if (!p || p.policyId !== r.policyId)
126
+ if (!p || p.policyId !== r.policyId) {
111
127
  fail('RESOURCE_NOT_FOUND', 'Permission not found on this policy.');
128
+ }
112
129
  permissions.delete(r.policyPermissionId);
113
130
  },
114
131
  };
@@ -135,24 +152,36 @@ export function createMemoryUsersApi({ workspaceId }, options = {}) {
135
152
  function write(id, action, o) {
136
153
  check(o);
137
154
  const user = get(id);
138
- if (trash.has(id))
155
+ if (trash.has(id)) {
139
156
  fail('USER_IN_TRASH', 'This user is in the Trash; restore them first.');
140
- if (action === 'delete' && id === caller.user.id)
157
+ }
158
+ if (action === 'delete' && id === caller.user.id) {
141
159
  fail('SELF_DELETE', 'You cannot delete your own account.');
142
- if (!userActionAllowed({ user, action, permissions: grants, caller, safety }))
160
+ }
161
+ if (!userActionAllowed({ user, action, permissions: grants, caller, safety })) {
143
162
  fail('FORBIDDEN', 'Mutation denied.');
163
+ }
144
164
  return user;
145
165
  }
146
- const password = (value) => { if (!value || [...value].length > 512)
147
- fail('VALIDATION_ERROR', 'Password must be present and no more than 512 characters.'); };
166
+ const password = (value) => {
167
+ if (!value || [...value].length > 512) {
168
+ fail('VALIDATION_ERROR', 'Password must be present and no more than 512 characters.');
169
+ }
170
+ };
148
171
  /** Disabling or deleting an active owner must leave another active owner behind. */
149
172
  function keepAnotherActiveOwner(u, _optional = {}) {
150
- if (u.status === 'active' && owners.has(u.principalId) && [...users.values()].filter(v => v.status === 'active' && owners.has(v.principalId)).length <= 1)
173
+ if (u.status === 'active' && owners.has(u.principalId) && [...users.values()].filter(v => v.status === 'active' && owners.has(v.principalId)).length <= 1) {
151
174
  fail('OWNER_REQUIRED', 'The workspace must keep at least one active owner.');
175
+ }
176
+ }
177
+ function clamp(contents) {
178
+ if (grants.includes('*')) {
179
+ return;
180
+ }
181
+ if (!contents || contents.some(p => !grants.includes(p))) {
182
+ fail('GRANT_EXCEEDS_ISSUER', 'Grant exceeds issuer.');
183
+ }
152
184
  }
153
- function clamp(contents) { if (grants.includes('*'))
154
- return; if (!contents || contents.some(p => !grants.includes(p)))
155
- fail('GRANT_EXCEEDS_ISSUER', 'Grant exceeds issuer.'); }
156
185
  return {
157
186
  async listUsers(_r, o = {}) { check(o); return { users: [...users.values()].filter(u => !trash.has(u.principalId)).map(clone) }; },
158
187
  async listRoles(_r, o = {}) { check(o); return { roles: roles.map(r => ({ ...r })) }; },
@@ -160,18 +189,22 @@ export function createMemoryUsersApi({ workspaceId }, options = {}) {
160
189
  async me(_r, o = {}) { check(o); return structuredClone(caller); },
161
190
  async createUser(r, o = {}) {
162
191
  check(o);
163
- if (!hasAdminGrant({ permissions: grants, permission: 'user.manage' }))
192
+ if (!hasAdminGrant({ permissions: grants, permission: 'user.manage' })) {
164
193
  fail('FORBIDDEN', 'Missing user.manage.');
194
+ }
165
195
  const username = r.username.trim().normalize('NFC').toLowerCase();
166
- if (!username)
196
+ if (!username) {
167
197
  fail('VALIDATION_ERROR', 'Username is required.');
198
+ }
168
199
  password(r.password);
169
200
  const existing = [...users.values()].find(u => u.username.normalize('NFC').toLowerCase() === username);
170
- if (existing)
201
+ if (existing) {
171
202
  fail(trash.has(existing.principalId) ? 'USERNAME_IN_TRASH' : 'RESOURCE_CONFLICT', 'Username already exists.');
203
+ }
172
204
  let principalId;
173
205
  do {
174
- principalId = `memory-user-${++sequence}`;
206
+ ++sequence;
207
+ principalId = `memory-user-${sequence}`;
175
208
  } while (users.has(principalId));
176
209
  const user = { principalId, workspaceId, username, status: 'active', createdAt: new Date(0).toISOString(), roleIds: [], policyIds: [], ...(o.email !== undefined ? { email: o.email } : {}) };
177
210
  users.set(principalId, user);
@@ -187,10 +220,24 @@ export function createMemoryUsersApi({ workspaceId }, options = {}) {
187
220
  },
188
221
  async enableUser(r, o = {}) { const u = write(r.principalId, 'enable', o); const user = { ...u, status: 'active' }; users.set(u.principalId, user); return { user: clone(user) }; },
189
222
  async resetUserPassword(r, o = {}) { write(r.principalId, 'reset', o); password(r.password); options.onPasswordReset?.(r); },
190
- async assignRole(r, o = {}) { const u = write(r.principalId, 'grants', o); if (!roles.some(v => v.id === r.roleId))
191
- fail('RESOURCE_NOT_FOUND', 'Role not found.'); clamp(options.rolePermissions?.[r.roleId]); users.set(u.principalId, { ...u, roleIds: [...new Set([...u.roleIds, r.roleId])] }); return { assignment: { principalId: u.principalId, roleId: r.roleId } }; },
192
- async attachPolicy(r, o = {}) { const u = write(r.principalId, 'grants', o); if (!policies.some(v => v.id === r.policyId))
193
- fail('RESOURCE_NOT_FOUND', 'Policy not found.'); clamp(options.policyPermissions?.[r.policyId]); users.set(u.principalId, { ...u, policyIds: [...new Set([...u.policyIds, r.policyId])] }); return { attachment: { principalId: u.principalId, policyId: r.policyId } }; },
223
+ async assignRole(r, o = {}) {
224
+ const u = write(r.principalId, 'grants', o);
225
+ if (!roles.some(v => v.id === r.roleId)) {
226
+ fail('RESOURCE_NOT_FOUND', 'Role not found.');
227
+ }
228
+ clamp(options.rolePermissions?.[r.roleId]);
229
+ users.set(u.principalId, { ...u, roleIds: [...new Set([...u.roleIds, r.roleId])] });
230
+ return { assignment: { principalId: u.principalId, roleId: r.roleId } };
231
+ },
232
+ async attachPolicy(r, o = {}) {
233
+ const u = write(r.principalId, 'grants', o);
234
+ if (!policies.some(v => v.id === r.policyId)) {
235
+ fail('RESOURCE_NOT_FOUND', 'Policy not found.');
236
+ }
237
+ clamp(options.policyPermissions?.[r.policyId]);
238
+ users.set(u.principalId, { ...u, policyIds: [...new Set([...u.policyIds, r.policyId])] });
239
+ return { attachment: { principalId: u.principalId, policyId: r.policyId } };
240
+ },
194
241
  async deleteUser(r, o = {}) { const u = write(r.principalId, 'delete', o); keepAnotherActiveOwner(u); users.set(u.principalId, { ...u, status: 'disabled' }); trash.add(u.principalId); },
195
242
  };
196
243
  }
@@ -198,21 +245,39 @@ export function createMemoryMembersApi({ workspaceId }, { members = [], permissi
198
245
  const grants = Object.freeze([...permissions]);
199
246
  const rows = new Map(inWorkspace(members, workspaceId).map(m => [m.id, { ...m }]));
200
247
  const fail = (code, message) => { throw new PeopleApiError({ code, message }); };
201
- const gate = (o) => { check(o); if (!hasAdminGrant({ permissions: grants, permission: 'member.manage' }))
202
- fail('FORBIDDEN', 'Missing member.manage.'); };
248
+ const gate = (o) => {
249
+ check(o);
250
+ if (!hasAdminGrant({ permissions: grants, permission: 'member.manage' })) {
251
+ fail('FORBIDDEN', 'Missing member.manage.');
252
+ }
253
+ };
203
254
  const get = (id) => rows.get(id) ?? fail('RESOURCE_NOT_FOUND', 'Member not found.');
204
255
  return {
205
256
  async listMembers(_r, o = {}) { check(o); const limit = o.limit !== undefined && Number.isFinite(o.limit) && o.limit > 0 ? Math.floor(o.limit) : 100; return { members: [...rows.values()].sort((a, b) => a.id.localeCompare(b.id)).filter(m => o.afterId === undefined || m.id > o.afterId).slice(0, limit).map(m => ({ ...m })) }; },
206
257
  async getMember(r, o = {}) { check(o); return { member: { ...get(r.id) } }; },
207
258
  async disableMember(r, o = {}) { gate(o); const old = get(r.id); const member = old.status === 'disabled' ? old : { ...old, status: 'disabled', version: old.version + 1, updatedAt: new Date(0).toISOString() }; rows.set(member.id, member); return { member: { ...member } }; },
208
- async requestMemberMagicLink(r, o = {}) { gate(o); if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(r.email.trim()))
209
- fail('VALIDATION_ERROR', 'Invalid email.'); return { delivered: true }; },
259
+ async requestMemberMagicLink(r, o = {}) {
260
+ gate(o);
261
+ const address = /^[^\s@]+@([^\s@]+)$/.exec(r.email.trim());
262
+ if (!address || !address[1].slice(1, -1).includes('.')) {
263
+ fail('VALIDATION_ERROR', 'Invalid email.');
264
+ }
265
+ return { delivered: true };
266
+ },
210
267
  };
211
268
  }
212
269
  /** Deterministic credential fake; production hashing/session policy stays in the server entry. */
213
270
  export function createMemoryAuthApi({ accounts }, _optional = {}) {
214
271
  const rows = accounts.map(a => ({ ...a, user: { ...a.user } }));
215
- return { async login(r, o = {}) { check(o); const found = rows.find(a => a.user.username.normalize('NFC').toLowerCase() === r.username.trim().normalize('NFC').toLowerCase()); if (!r.password || !found || found.disabled || found.password !== r.password)
216
- throw new PeopleApiError({ code: 'UNAUTHENTICATED', message: 'invalid username or password' }); return { user: { ...found.user } }; } };
272
+ return {
273
+ async login(r, o = {}) {
274
+ check(o);
275
+ const found = rows.find(a => a.user.username.normalize('NFC').toLowerCase() === r.username.trim().normalize('NFC').toLowerCase());
276
+ if (!r.password || !found || found.disabled || found.password !== r.password) {
277
+ throw new PeopleApiError({ code: 'UNAUTHENTICATED', message: 'invalid username or password' });
278
+ }
279
+ return { user: { ...found.user } };
280
+ }
281
+ };
217
282
  }
218
283
  //# sourceMappingURL=memory.js.map
@@ -1,11 +1,12 @@
1
+ function assert(value, message) {
2
+ if (!value) {
3
+ throw new Error(message);
4
+ }
5
+ }
1
6
  /** Mutates a disposable, authenticated backend. Caller must hold role.manage and content.read.
2
7
  * Permission vocabulary can be supplied for another server's catalog. Cleanup failures are failures. */
3
8
  export async function runRolesApiConformance({ api }, { permission = 'content.read', prefix = `conformance-${Date.now()}` } = {}) {
4
9
  const passed = [], failed = [];
5
- const assert = (value, message) => {
6
- if (!value)
7
- throw new Error(message);
8
- };
9
10
  async function check(name, run) {
10
11
  try {
11
12
  await run();
@@ -56,12 +57,15 @@ export async function runRolesApiConformance({ api }, { permission = 'content.re
56
57
  await check('policy deletion cascades own permissions', async () => { await api.writePolicyPermission({ policyId: policyId, permission }); await api.deletePolicy({ policyId: policyId }); assert(!(await api.listPolicies({})).policies.some(p => p.id === policyId), 'policy deletion absent'); policyId = undefined; });
57
58
  }
58
59
  finally {
59
- if (roleId)
60
+ if (roleId) {
60
61
  await check('cleanup role', async () => { await api.deleteRole({ roleId: roleId }); assert(!(await api.listRoles({})).roles.some(r => r.id === roleId), 'role deletion absent'); });
61
- if (policyId)
62
+ }
63
+ if (policyId) {
62
64
  await check('cleanup policy', async () => { await api.deletePolicy({ policyId: policyId }); });
63
- if (otherPolicyId)
65
+ }
66
+ if (otherPolicyId) {
64
67
  await check('cleanup other policy', async () => { await api.deletePolicy({ policyId: otherPolicyId }); });
68
+ }
65
69
  }
66
70
  return { passed, failed };
67
71
  }
@@ -69,8 +73,6 @@ export async function runRolesApiConformance({ api }, { permission = 'content.re
69
73
  * to the host. Members are disable-only, so conformance takes a dedicated fixture id. */
70
74
  export async function runUsersApiConformance({ api }, { prefix = `users-${Date.now()}`, roleId, policyId } = {}) {
71
75
  const passed = [], failed = [];
72
- const assert = (value, message) => { if (!value)
73
- throw new Error(message); };
74
76
  const check = async (name, run) => { try {
75
77
  await run();
76
78
  passed.push(name);
@@ -87,10 +89,12 @@ export async function runUsersApiConformance({ api }, { prefix = `users-${Date.n
87
89
  await check('disable and enable operator', async () => { assert((await api.disableUser({ principalId: principalId })).user.status === 'disabled', 'disable missing'); assert((await api.enableUser({ principalId: principalId })).user.status === 'active', 'enable missing'); });
88
90
  // eslint-disable-next-line sonarjs/no-hardcoded-passwords -- Disposable-user conformance fixture for an explicitly isolated authenticated host; this is not an application credential.
89
91
  await check('reset password', async () => { await api.resetUserPassword({ principalId: principalId, password: 'replacement' }); });
90
- if (roleId)
92
+ if (roleId) {
91
93
  await check('assign role', async () => { await api.assignRole({ principalId: principalId, roleId }); assert((await api.listUsers({})).users.find(u => u.principalId === principalId)?.roleIds.includes(roleId), 'role missing'); });
92
- if (policyId)
94
+ }
95
+ if (policyId) {
93
96
  await check('attach policy', async () => { await api.attachPolicy({ principalId: principalId, policyId }); assert((await api.listUsers({})).users.find(u => u.principalId === principalId)?.policyIds.includes(policyId), 'policy missing'); });
97
+ }
94
98
  await check('aborted write does not persist', async () => { const abort = new AbortController(); abort.abort(); let rejected = false; try {
95
99
  await api.updateUser({ principalId: principalId }, { email: 'bad@example.test', signal: abort.signal });
96
100
  }
@@ -99,15 +103,14 @@ export async function runUsersApiConformance({ api }, { prefix = `users-${Date.n
99
103
  } assert(rejected && (await api.listUsers({})).users.find(u => u.principalId === principalId)?.email === 'after@example.test', 'abort ignored'); });
100
104
  }
101
105
  finally {
102
- if (principalId)
106
+ if (principalId) {
103
107
  await check('delete moves out of roster', async () => { await api.deleteUser({ principalId: principalId }); assert(!(await api.listUsers({})).users.some(u => u.principalId === principalId), 'trashed user still listed'); });
108
+ }
104
109
  }
105
110
  return { passed, failed };
106
111
  }
107
112
  export async function runMembersApiConformance({ api, memberId }, _optional = {}) {
108
113
  const passed = [], failed = [];
109
- const assert = (value, message) => { if (!value)
110
- throw new Error(message); };
111
114
  const check = async (name, run) => { try {
112
115
  await run();
113
116
  passed.push(name);
@@ -136,22 +139,37 @@ export async function runAuthApiConformance({ api, credentials, expectedUserId }
136
139
  catch (error) {
137
140
  failed.push({ name, error });
138
141
  } };
139
- await check('login returns expected account', async () => { if ((await api.login(credentials)).user.id !== expectedUserId)
140
- throw new Error('wrong account'); });
141
- await check('empty password is rejected', async () => { let rejected = false; try {
142
- await api.login({ ...credentials, password: '' });
143
- }
144
- catch {
145
- rejected = true;
146
- } if (!rejected)
147
- throw new Error('empty password authenticated'); });
148
- await check('aborted login is rejected', async () => { const abort = new AbortController(); abort.abort(); let rejected = false; try {
149
- await api.login(credentials, { signal: abort.signal });
150
- }
151
- catch {
152
- rejected = true;
153
- } if (!rejected)
154
- throw new Error('abort ignored'); });
142
+ await check('login returns expected account', async () => {
143
+ if ((await api.login(credentials)).user.id !== expectedUserId) {
144
+ throw new Error('wrong account');
145
+ }
146
+ });
147
+ await check('empty password is rejected', async () => {
148
+ let rejected = false;
149
+ try {
150
+ await api.login({ ...credentials, password: '' });
151
+ }
152
+ catch {
153
+ rejected = true;
154
+ }
155
+ if (!rejected) {
156
+ throw new Error('empty password authenticated');
157
+ }
158
+ });
159
+ await check('aborted login is rejected', async () => {
160
+ const abort = new AbortController();
161
+ abort.abort();
162
+ let rejected = false;
163
+ try {
164
+ await api.login(credentials, { signal: abort.signal });
165
+ }
166
+ catch {
167
+ rejected = true;
168
+ }
169
+ if (!rejected) {
170
+ throw new Error('abort ignored');
171
+ }
172
+ });
155
173
  return { passed, failed };
156
174
  }
157
175
  //# sourceMappingURL=index.js.map
@@ -7,16 +7,28 @@ export function createControllerStore({ initial }, _optional = {}) {
7
7
  getSnapshot(_required = {}, _optional = {}) { return state; },
8
8
  active(_required = {}, _optional = {}) { return !disposed; },
9
9
  set({ patch }, _optional = {}) {
10
- if (disposed)
10
+ if (disposed) {
11
11
  return;
12
+ }
12
13
  state = Object.freeze({ ...state, ...patch });
13
- for (const listener of listeners)
14
+ for (const listener of listeners) {
14
15
  listener();
16
+ }
17
+ },
18
+ subscribe({ listener }, _optional = {}) {
19
+ if (!disposed) {
20
+ listeners.add(listener);
21
+ }
22
+ return () => { listeners.delete(listener); };
23
+ },
24
+ dispose(_required = {}, _optional = {}) {
25
+ if (disposed) {
26
+ return;
27
+ }
28
+ disposed = true;
29
+ abort.abort();
30
+ listeners.clear();
15
31
  },
16
- subscribe({ listener }, _optional = {}) { if (!disposed)
17
- listeners.add(listener); return () => { listeners.delete(listener); }; },
18
- dispose(_required = {}, _optional = {}) { if (disposed)
19
- return; disposed = true; abort.abort(); listeners.clear(); },
20
32
  call: { signal: abort.signal },
21
33
  };
22
34
  }
@@ -15,57 +15,73 @@ export function createMembersController({ api }, { permissions = [], refresh } =
15
15
  function patchRow(id, patch) { set({ rows: Object.freeze({ ...read({}).rows, [id]: Object.freeze({ ...stateFor({ id }), ...patch }) }) }); }
16
16
  const gate = () => store.active({}) && hasAdminGrant({ permissions: grants, permission: 'member.manage' });
17
17
  async function loadDetail(id) {
18
- const generation = ++detailGeneration;
18
+ ++detailGeneration;
19
+ const generation = detailGeneration;
19
20
  set({ detailLoadingId: id, detailError: null });
20
21
  try {
21
22
  const { member } = await api.getMember({ id }, call);
22
- if (generation === detailGeneration && read({}).expandedId === id)
23
+ if (generation === detailGeneration && read({}).expandedId === id) {
23
24
  set({ detailById: Object.freeze({ ...read({}).detailById, [id]: Object.freeze({ ...member }) }) });
25
+ }
24
26
  }
25
27
  catch (error) {
26
- if (generation === detailGeneration && read({}).expandedId === id)
28
+ if (generation === detailGeneration && read({}).expandedId === id) {
27
29
  set({ detailError: report(error, 'Failed to load member detail.') });
30
+ }
28
31
  }
29
32
  finally {
30
- if (generation === detailGeneration)
33
+ if (generation === detailGeneration) {
31
34
  set({ detailLoadingId: null });
35
+ }
32
36
  }
33
37
  }
34
38
  async function load(_required, _optional = {}) {
35
- if (!store.active({}))
39
+ if (!store.active({})) {
36
40
  return;
37
- const generation = ++listGeneration;
41
+ }
42
+ ++listGeneration;
43
+ const generation = listGeneration;
38
44
  set({ error: null });
39
45
  try {
40
46
  const { members } = await api.listMembers({}, call);
41
- if (generation !== listGeneration || !store.active({}))
47
+ if (generation !== listGeneration || !store.active({})) {
42
48
  return;
49
+ }
43
50
  ++detailGeneration;
44
51
  set({ members: Object.freeze(members.map(m => Object.freeze({ ...m }))), detailById: {}, detailError: null, detailLoadingId: null });
45
52
  const id = read({}).expandedId;
46
- if (id && row(id))
53
+ if (id && row(id)) {
47
54
  await loadDetail(id);
48
- else if (id)
55
+ }
56
+ else if (id) {
49
57
  set({ expandedId: null });
58
+ }
59
+ else {
60
+ // With no expanded member, there is no detail selection to reconcile.
61
+ }
50
62
  }
51
63
  catch (error) {
52
- if (generation === listGeneration)
64
+ if (generation === listGeneration) {
53
65
  set({ error: report(error, 'failed to load members') });
66
+ }
54
67
  }
55
68
  }
56
69
  async function toggleDetail({ id }, _optional = {}) {
57
- if (!store.active({}) || !row(id))
70
+ if (!store.active({}) || !row(id)) {
58
71
  return;
72
+ }
59
73
  ++detailGeneration;
60
74
  const next = read({}).expandedId === id ? null : id;
61
75
  set({ expandedId: next, detailError: null, detailLoadingId: null });
62
- if (next && !read({}).detailById[next])
76
+ if (next && !read({}).detailById[next]) {
63
77
  await loadDetail(next);
78
+ }
64
79
  }
65
80
  async function resendSignInLink({ id }, _optional = {}) {
66
81
  const member = row(id);
67
- if (!member || !gate() || stateFor({ id }).resending)
82
+ if (!member || !gate() || stateFor({ id }).resending) {
68
83
  return false;
84
+ }
69
85
  patchRow(id, { resending: true, error: null, notice: null });
70
86
  let sent = false;
71
87
  try {
@@ -83,8 +99,9 @@ export function createMembersController({ api }, { permissions = [], refresh } =
83
99
  }
84
100
  async function confirmDestructive(_required, _optional = {}) {
85
101
  const target = read({}).pending;
86
- if (!target || !gate() || read({}).confirming || !row(target.id))
102
+ if (!target || !gate() || read({}).confirming || !row(target.id)) {
87
103
  return false;
104
+ }
88
105
  set({ confirming: true });
89
106
  patchRow(target.id, { disabling: true, error: null, notice: null });
90
107
  let disabled = false;
@@ -93,8 +110,10 @@ export function createMembersController({ api }, { permissions = [], refresh } =
93
110
  // Invalidate older reads before applying the write response, including the cached detail.
94
111
  ++listGeneration;
95
112
  ++detailGeneration;
96
- set({ members: Object.freeze(list().map(m => m.id === member.id ? Object.freeze({ ...member }) : m)),
97
- detailById: Object.freeze({ ...read({}).detailById, [member.id]: Object.freeze({ ...member }) }), detailLoadingId: null });
113
+ set({
114
+ members: Object.freeze(list().map(m => m.id === member.id ? Object.freeze({ ...member }) : m)),
115
+ detailById: Object.freeze({ ...read({}).detailById, [member.id]: Object.freeze({ ...member }) }), detailLoadingId: null
116
+ });
98
117
  patchRow(target.id, { notice: 'Member disabled.' });
99
118
  disabled = store.active({});
100
119
  }
@@ -103,8 +122,9 @@ export function createMembersController({ api }, { permissions = [], refresh } =
103
122
  }
104
123
  finally {
105
124
  patchRow(target.id, { disabling: false });
106
- if (read({}).pending === target)
125
+ if (read({}).pending === target) {
107
126
  set({ pending: null });
127
+ }
108
128
  set({ confirming: false });
109
129
  }
110
130
  return disabled;
@@ -112,10 +132,17 @@ export function createMembersController({ api }, { permissions = [], refresh } =
112
132
  const unsubscribe = refresh?.subscribe({ onRefresh: () => { void load({}); } });
113
133
  return {
114
134
  getSnapshot: store.getSnapshot, subscribe: store.subscribe, load, stateFor, toggleDetail, resendSignInLink, confirmDestructive,
115
- requestDestructive({ id }, _optional = {}) { const member = row(id); if (gate() && member && member.status !== 'disabled' && !read({}).confirming)
116
- set({ pending: Object.freeze({ ...member }) }); },
117
- cancelDestructive(_required, _optional = {}) { if (!read({}).confirming)
118
- set({ pending: null }); },
135
+ requestDestructive({ id }, _optional = {}) {
136
+ const member = row(id);
137
+ if (gate() && member && member.status !== 'disabled' && !read({}).confirming) {
138
+ set({ pending: Object.freeze({ ...member }) });
139
+ }
140
+ },
141
+ cancelDestructive(_required, _optional = {}) {
142
+ if (!read({}).confirming) {
143
+ set({ pending: null });
144
+ }
145
+ },
119
146
  dispose(_required = {}, _optional = {}) { unsubscribe?.(); store.dispose({}); ++listGeneration; ++detailGeneration; },
120
147
  };
121
148
  }