@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.
- package/README.md +2 -0
- package/dist/admin/adapters/http.js +15 -5
- package/dist/admin/adapters/memory.js +103 -38
- package/dist/admin/conformance/index.js +48 -30
- package/dist/admin/controllers/controller-store.js +18 -6
- package/dist/admin/controllers/members.controller.js +48 -21
- package/dist/admin/controllers/roles.controller.js +98 -49
- package/dist/admin/controllers/users.controller.js +22 -11
- package/dist/admin/error-copy.js +3 -1
- package/dist/admin/ports.d.ts +11 -10
- package/dist/admin/react/components/PermissionPanel.js +3 -2
- package/dist/admin/react/components/PolicyRow.js +3 -2
- package/dist/admin/react/components/RoleRow.js +3 -2
- package/dist/admin/react/components/UsersConfirmation.js +1 -2
- package/dist/admin/react/hooks/AuthScope.hooks.js +2 -1
- package/dist/admin/react/hooks/MembersPage.hooks.js +23 -10
- package/dist/admin/react/hooks/MembersScope.hooks.js +2 -1
- package/dist/admin/react/hooks/UsersPage.hooks.js +13 -5
- package/dist/admin/react/hooks/UsersScope.hooks.js +2 -1
- package/dist/admin/react/index.d.ts +6 -6
- package/dist/admin/rules.js +15 -7
- package/dist/core/ports.d.ts +6 -0
- package/dist/react/errors.js +3 -1
- package/dist/react/users/Users.js +2 -1
- package/dist/react/users/rules.js +9 -5
- package/dist/server/admin-crud-service.js +17 -13
- package/dist/server/agent-tool-registrations.js +18 -15
- package/dist/server/agent-tools.js +38 -31
- package/dist/server/auth-challenge.js +4 -3
- package/dist/server/hasher.js +2 -1
- package/dist/server/repo.memory-transactions.js +13 -9
- package/dist/server/repo.memory.d.ts +3 -0
- package/dist/server/repo.memory.js +8 -0
- package/dist/server/seed.d.ts +17 -1
- package/dist/server/seed.js +25 -2
- 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 = {}) => {
|
|
56
|
-
query
|
|
57
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
157
|
+
}
|
|
158
|
+
if (action === 'delete' && id === caller.user.id) {
|
|
141
159
|
fail('SELF_DELETE', 'You cannot delete your own account.');
|
|
142
|
-
|
|
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) => {
|
|
147
|
-
|
|
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
|
-
|
|
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 = {}) {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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) => {
|
|
202
|
-
|
|
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 = {}) {
|
|
209
|
-
|
|
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 {
|
|
216
|
-
|
|
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
|
-
|
|
62
|
+
}
|
|
63
|
+
if (policyId) {
|
|
62
64
|
await check('cleanup policy', async () => { await api.deletePolicy({ policyId: policyId }); });
|
|
63
|
-
|
|
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
|
-
|
|
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 () => {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
rejected =
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
rejected
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
97
|
-
|
|
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 = {}) {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
}
|