@andrian.yablonskyy/thub-coordinator 1.0.43 → 1.1.1

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.
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * @file packages/coordinator/src/services/admin-users.js
3
- * @description Admin user accounts: password hashing/verification and per-user profile settings (§10.1)
3
+ * @description User accounts (§10.3): roles, blocking, passwords, each user's access key, and per-user profile settings (§10.1)
4
4
  *
5
5
  * @author Andrian Yablonskyy
6
6
  * @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
@@ -16,7 +16,8 @@
16
16
  const crypto = require('node:crypto'),
17
17
  { promisify } = require('node:util'),
18
18
  { v4: uuid } = require('uuid'),
19
- { normalize: normalizeListPrefs } = require('./list-prefs');
19
+ { normalize: normalizeListPrefs } = require('./list-prefs'),
20
+ { generateToken, hashToken } = require('./tokens');
20
21
 
21
22
  // Stored as "<salt hex>:<scrypt hex>". The synchronous one is only for the
22
23
  // CLI and startup (thub-admin create-admin, the bootstrap password); every
@@ -36,7 +37,7 @@ async function hashPasswordAsync(password){
36
37
  }
37
38
 
38
39
  async function verifyPassword(password, stored){
39
- const [salt, derived] = stored.split(':'),
40
+ const [salt, derived] = (stored || DUMMY_HASH).split(':'),
40
41
  check = await scrypt(String(password ?? ''), salt, 64);
41
42
  return crypto.timingSafeEqual(check, Buffer.from(derived, 'hex'));
42
43
  }
@@ -50,12 +51,48 @@ const DUMMY_HASH = hashPassword(crypto.randomBytes(16).toString('hex')),
50
51
  // free-text field, so a user can't accidentally set a 3-second or 10-year
51
52
  // idle timeout.
52
53
  SESSION_TIMEOUT_OPTIONS_MIN = [15, 30, 60, 120, 240, 480, 1440],
53
- THEMES = ['auto', 'light', 'dark'];
54
+ THEMES = ['auto', 'light', 'dark'],
55
+
56
+ // §10.3. user: the Agent only (submit jobs, read their own results);
57
+ // maintainer: + the dashboard, without its security features; admin: all.
58
+ ROLES = ['user', 'maintainer', 'admin'],
59
+ DASHBOARD_ROLES = ['maintainer', 'admin'],
60
+ USERNAME_RE = /^[A-Za-z0-9._@+-]{1,64}$/,
61
+ EMAIL_RE = /^[^\s@]{1,64}@[^\s@]{1,190}\.[^\s@]{2,63}$/,
62
+ // Access keys: thk_ + 256 random bits, stored hashed like every token (§12).
63
+ KEY_PREFIX = 'thk';
64
+
65
+ // How a role is shown: User, Maintainer, Admin (stored and typed lowercase).
66
+ function roleLabel(role){
67
+ return role ? role.charAt(0).toUpperCase() + role.slice(1) : role;
68
+ }
69
+
70
+ // The role a request names, in any case: `viewer` (before §10.3) is now
71
+ // maintainer.
72
+ function normalizeRole(role){
73
+ const lower = String(role ?? '').trim().toLowerCase(),
74
+ r = lower === 'viewer' ? 'maintainer' : lower;
75
+ if (!ROLES.includes(r)){
76
+ throw Object.assign(new Error(`role must be one of: ${ROLES.join(', ')}`), { status: 400 });
77
+ }
78
+ return r;
79
+ }
80
+
81
+ function bad(message, status = 400){
82
+ return Object.assign(new Error(message), { status });
83
+ }
84
+
85
+ // A readable one-time password: shown once to the admin, changed at the
86
+ // first sign-in.
87
+ function tempPassword(){
88
+ return crypto.randomBytes(12).toString('base64url');
89
+ }
54
90
 
55
91
  function toProfile(row){
56
92
  return {
57
93
  id: row.id,
58
94
  username: row.username,
95
+ email: row.email || '',
59
96
  role: row.role,
60
97
  firstName: row.first_name,
61
98
  lastName: row.last_name,
@@ -63,7 +100,12 @@ function toProfile(row){
63
100
  timezone: row.timezone,
64
101
  theme: row.theme,
65
102
  sessionTimeoutMin: row.session_timeout_min,
66
- listPrefs: parseListPrefs(row.list_prefs)
103
+ listPrefs: parseListPrefs(row.list_prefs),
104
+ blocked: Boolean(row.blocked_at),
105
+ blockedAt: row.blocked_at || null,
106
+ mustChangePassword: Boolean(row.must_change_password),
107
+ hasPassword: Boolean(row.password_hash),
108
+ canUseDashboard: DASHBOARD_ROLES.includes(row.role)
67
109
  };
68
110
  }
69
111
 
@@ -77,67 +119,257 @@ function parseListPrefs(json){
77
119
  }
78
120
  }
79
121
 
80
- // §10: dashboard login uses its own admin/viewer account table with
81
- // session cookies — deliberately separate from the API bearer tokens.
82
- function createAdminUsersService(db){
122
+ // §10.3: every account — dashboard sign-in for maintainers and admins, an
123
+ // access key for the Agent for anyone. `events` (the audit log) is optional
124
+ // so the profile/password functions can be used on their own (tests).
125
+ function createAdminUsersService(db, { events } = {}){
126
+ const audit = (id, type, data) => events?.record('user', id, type, data);
127
+
83
128
  function getByUsername(username){
84
- return db.prepare('SELECT * FROM admin_users WHERE username = ?').get(username);
129
+ return db.prepare('SELECT * FROM users WHERE username = ?').get(username);
85
130
  }
86
131
 
87
132
  function getById(id){
88
- const row = db.prepare('SELECT * FROM admin_users WHERE id = ?').get(id);
133
+ const row = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
89
134
  return row ? toProfile(row) : null;
90
135
  }
91
136
 
92
137
  function count(){
93
- return db.prepare('SELECT COUNT(*) AS n FROM admin_users').get().n;
138
+ return db.prepare('SELECT COUNT(*) AS n FROM users').get().n;
94
139
  }
95
140
 
96
- function create({ username, password, role }){
97
- const id = `usr_${uuid()}`;
98
- db.prepare('INSERT INTO admin_users (id, username, password_hash, role, created_at) VALUES (?, ?, ?, ?, ?)').run(
99
- id,
100
- username,
101
- hashPassword(password),
102
- role,
103
- new Date().toISOString()
104
- );
105
- return { id, username, role };
141
+ function activeAdmins(){
142
+ return db.prepare('SELECT COUNT(*) AS n FROM users WHERE role = \'admin\' AND blocked_at IS NULL').get().n;
143
+ }
144
+
145
+ // The access key of a user: its agents row (hash, hint, last use, the Agent
146
+ // version it reported), or null.
147
+ function keyOf(userId){
148
+ return db.prepare('SELECT * FROM agents WHERE user_id = ?').get(userId) || null;
149
+ }
150
+
151
+ // Users page: every user with their key's state.
152
+ function list(){
153
+ return db.prepare(
154
+ `SELECT u.*, a.id AS key_id, a.token_hint, a.token_created_at, a.last_used_at AS key_last_used_at,
155
+ a.revoked_at AS key_revoked_at, a.version AS agent_version, a.update_to AS agent_update_to
156
+ FROM users u LEFT JOIN agents a ON a.user_id = u.id
157
+ ORDER BY u.username COLLATE NOCASE`
158
+ ).all().map((row) => ({
159
+ ...toProfile(row),
160
+ createdAt: row.created_at,
161
+ key: row.key_id && !row.key_revoked_at
162
+ ? { id: row.key_id, hint: row.token_hint, createdAt: row.token_created_at, lastUsedAt: row.key_last_used_at,
163
+ version: row.agent_version, updateTo: row.agent_update_to }
164
+ : null
165
+ }));
166
+ }
167
+
168
+ function checkUsername(username, exceptId){
169
+ const name = String(username ?? '').trim();
170
+ if (!USERNAME_RE.test(name)){
171
+ throw bad('Username: 1–64 letters, digits or . _ @ + -');
172
+ }
173
+ const owner = getByUsername(name);
174
+ if (owner && owner.id !== exceptId){
175
+ throw bad(`Username "${name}" is already taken`, 409);
176
+ }
177
+ return name;
178
+ }
179
+
180
+ function checkEmail(email, exceptId){
181
+ const value = String(email ?? '').trim();
182
+ if (!EMAIL_RE.test(value)){
183
+ throw bad('Email: a valid address, like jane@example.com');
184
+ }
185
+ const owner = db.prepare('SELECT id FROM users WHERE lower(email) = lower(?)').get(value);
186
+ if (owner && owner.id !== exceptId){
187
+ throw bad(`Email "${value}" is already used by another user`, 409);
188
+ }
189
+ return value;
190
+ }
191
+
192
+ // Nobody may remove the last way in: the last active admin can't be
193
+ // blocked, demoted or deleted, and an admin can't do that to themselves.
194
+ function guardAdmins(target, { actorId, losesAdmin }){
195
+ if (!losesAdmin || target.role !== 'admin' || target.blocked_at){
196
+ return;
197
+ }
198
+ if (actorId && actorId === target.id){
199
+ throw bad('You can\'t block, demote or delete your own account — ask another admin', 409);
200
+ }
201
+ if (activeAdmins() <= 1){
202
+ throw bad('This is the last active admin — make someone else an admin first', 409);
203
+ }
204
+ }
205
+
206
+ // `email` is required where people create users (dashboard, thub-admin
207
+ // user add); the bootstrap admin (THUB_BOOTSTRAP_ADMIN_PASSWORD) and older
208
+ // callers may leave it out — the Users page flags a missing one.
209
+ // A dashboard role without a password gets a temporary one, returned
210
+ // once, to be changed at the first sign-in.
211
+ function create({ username, email, role, password, firstName, lastName, requireEmail = false }, { by } = {}){
212
+ const id = `usr_${uuid()}`,
213
+ name = checkUsername(username),
214
+ r = normalizeRole(role),
215
+ mail = email || requireEmail ? checkEmail(email) : '',
216
+ temp = !password && DASHBOARD_ROLES.includes(r) ? tempPassword() : null,
217
+ pw = password || temp;
218
+ db.prepare(
219
+ `INSERT INTO users (id, username, email, password_hash, role, first_name, last_name, must_change_password, created_at)
220
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
221
+ ).run(id, name, mail, pw ? hashPassword(pw) : null, r, firstName || null, lastName || null, temp ? 1 : 0, new Date().toISOString());
222
+ audit(id, 'user.created', { by, username: name, role: r });
223
+ return { id, username: name, role: r, tempPassword: temp };
106
224
  }
107
225
 
108
226
  // Exceptional password reset (README §13.1): resets an *existing*
109
227
  // user's password without touching their role (a reset shouldn't
110
- // silently promote a viewer to admin), or creates one as admin if the
228
+ // silently promote anyone to admin), or creates one as admin if the
111
229
  // named account doesn't exist yet — the original first-run bootstrap
112
230
  // case, now handled by the same path.
113
231
  function resetPassword({ username, password, role = 'admin' }){
114
232
  const existing = getByUsername(username);
115
233
  if (existing){
116
- db.prepare('UPDATE admin_users SET password_hash = ? WHERE id = ?').run(hashPassword(password), existing.id);
234
+ db.prepare('UPDATE users SET password_hash = ?, must_change_password = 0 WHERE id = ?').run(hashPassword(password), existing.id);
117
235
  return { id: existing.id, username, role: existing.role };
118
236
  }
119
237
  return create({ username, password, role });
120
238
  }
121
239
 
240
+ // Users page: a new temporary password for someone who lost theirs.
241
+ function adminResetPassword(id, { by } = {}){
242
+ const target = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
243
+ if (!target){
244
+ throw bad('Unknown user', 404);
245
+ }
246
+ if (!DASHBOARD_ROLES.includes(target.role)){
247
+ throw bad(`${target.username} has no dashboard access (role User) — there's no password to reset`);
248
+ }
249
+ const temp = tempPassword();
250
+ db.prepare('UPDATE users SET password_hash = ?, must_change_password = 1 WHERE id = ?').run(hashPassword(temp), id);
251
+ audit(id, 'user.password_reset', { by });
252
+ return temp;
253
+ }
254
+
122
255
  // Self-service password change (§10.1) — requires knowing the *current*
123
- // password, unlike resetPassword() above (that's the admin/operator
124
- // break-glass path, which deliberately doesn't need it).
256
+ // password, unlike the resets above. Clears "must change".
125
257
  async function changePassword(id, currentPassword, newPassword){
126
- const row = db.prepare('SELECT * FROM admin_users WHERE id = ?').get(id);
127
- if (!row || !(await verifyPassword(currentPassword, row.password_hash))){
258
+ const row = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
259
+ if (!row || !(await verifyPassword(currentPassword, row.password_hash)) || !row.password_hash){
128
260
  throw Object.assign(new Error('Current password is incorrect'), { status: 401 });
129
261
  }
130
262
  if (!newPassword){
131
263
  throw Object.assign(new Error('New password must not be empty'), { status: 400 });
132
264
  }
133
- db.prepare('UPDATE admin_users SET password_hash = ? WHERE id = ?').run(await hashPasswordAsync(newPassword), id);
265
+ if (newPassword === currentPassword){
266
+ throw bad('Choose a password different from the current one');
267
+ }
268
+ db.prepare('UPDATE users SET password_hash = ?, must_change_password = 0 WHERE id = ?').run(await hashPasswordAsync(newPassword), id);
269
+ audit(id, 'user.password_changed', {});
134
270
  }
135
271
 
136
- // Async (thread pool); the same work whether or not the user exists.
272
+ // Async (thread pool); the same work whether or not the user exists, or
273
+ // has a password at all.
137
274
  async function verify(username, password){
138
275
  const user = getByUsername(String(username ?? '')),
139
276
  ok = await verifyPassword(password, user ? user.password_hash : DUMMY_HASH);
140
- return user && ok ? toProfile(user) : null;
277
+ return user && user.password_hash && ok ? toProfile(user) : null;
278
+ }
279
+
280
+ // Users page edit: username, email, role, names. Guards the last admin.
281
+ function update(id, fields, { by, actorId } = {}){
282
+ const target = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
283
+ if (!target){
284
+ throw bad('Unknown user', 404);
285
+ }
286
+ const next = {
287
+ username: fields.username !== undefined ? checkUsername(fields.username, id) : target.username,
288
+ email: fields.email !== undefined ? checkEmail(fields.email, id) : target.email,
289
+ role: fields.role !== undefined ? normalizeRole(fields.role) : target.role,
290
+ first_name: fields.firstName !== undefined ? fields.firstName || null : target.first_name,
291
+ last_name: fields.lastName !== undefined ? fields.lastName || null : target.last_name
292
+ };
293
+ guardAdmins(target, { actorId, losesAdmin: next.role !== 'admin' });
294
+ // Promoted to the dashboard without a password: a temporary one.
295
+ const temp = DASHBOARD_ROLES.includes(next.role) && !target.password_hash ? tempPassword() : null;
296
+ db.transaction(() => {
297
+ db.prepare('UPDATE users SET username = ?, email = ?, role = ?, first_name = ?, last_name = ? WHERE id = ?')
298
+ .run(next.username, next.email, next.role, next.first_name, next.last_name, id);
299
+ if (temp){
300
+ db.prepare('UPDATE users SET password_hash = ?, must_change_password = 1 WHERE id = ?').run(hashPassword(temp), id);
301
+ }
302
+ // The key's agents row carries the name the job lists show.
303
+ db.prepare('UPDATE agents SET name = ? WHERE user_id = ?').run(next.username, id);
304
+ })();
305
+ const changed = Object.keys(next).filter((k) => next[k] !== target[k]);
306
+ if (changed.length){
307
+ audit(id, 'user.updated', { by, changed, ...(next.role !== target.role ? { role: { from: target.role, to: next.role } } : {}) });
308
+ }
309
+ return { user: getById(id), tempPassword: temp };
310
+ }
311
+
312
+ // Block: no sign-in, no Agent — at once (the session check and the key
313
+ // check read it on every request). Unblock gives both back.
314
+ function setBlocked(id, blocked, { by, actorId } = {}){
315
+ const target = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
316
+ if (!target){
317
+ throw bad('Unknown user', 404);
318
+ }
319
+ if (blocked){
320
+ guardAdmins(target, { actorId, losesAdmin: true });
321
+ }
322
+ db.prepare('UPDATE users SET blocked_at = ? WHERE id = ?').run(blocked ? new Date().toISOString() : null, id);
323
+ audit(id, blocked ? 'user.blocked' : 'user.unblocked', { by });
324
+ return getById(id);
325
+ }
326
+
327
+ // Delete: the account goes; its jobs stay, still listed under its name —
328
+ // its key's agents row is kept, revoked and unlinked.
329
+ function remove(id, { by, actorId } = {}){
330
+ const target = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
331
+ if (!target){
332
+ throw bad('Unknown user', 404);
333
+ }
334
+ guardAdmins(target, { actorId, losesAdmin: true });
335
+ db.transaction(() => {
336
+ db.prepare('UPDATE agents SET user_id = NULL, revoked_at = COALESCE(revoked_at, ?) WHERE user_id = ?').run(new Date().toISOString(), id);
337
+ db.prepare('DELETE FROM users WHERE id = ?').run(id);
338
+ })();
339
+ audit(id, 'user.deleted', { by, username: target.username });
340
+ }
341
+
342
+ // A new access key for the user — their first, or one replacing the old
343
+ // (which stops working at once). Returned once; only its hash and last
344
+ // characters are kept. The key's row stays the same, so the user's jobs
345
+ // stay theirs.
346
+ function issueKey(userId, { by } = {}){
347
+ const user = db.prepare('SELECT * FROM users WHERE id = ?').get(userId);
348
+ if (!user){
349
+ throw bad('Unknown user', 404);
350
+ }
351
+ const key = generateToken(KEY_PREFIX),
352
+ now = new Date().toISOString(),
353
+ existing = keyOf(userId);
354
+ if (existing){
355
+ db.prepare('UPDATE agents SET token_hash = ?, token_hint = ?, token_created_at = ?, revoked_at = NULL, name = ? WHERE id = ?')
356
+ .run(hashToken(key), key.slice(-4), now, user.username, existing.id);
357
+ }
358
+ else {
359
+ db.prepare(`INSERT INTO agents (id, name, kind, token_hash, token_hint, token_created_at, created_at, user_id)
360
+ VALUES (?, ?, 'cli', ?, ?, ?, ?, ?)`)
361
+ .run(`agt_${uuid()}`, user.username, hashToken(key), key.slice(-4), now, now, userId);
362
+ }
363
+ audit(userId, existing ? 'user.key_rotated' : 'user.key_created', { by });
364
+ return key;
365
+ }
366
+
367
+ function revokeKey(userId, { by } = {}){
368
+ const n = db.prepare('UPDATE agents SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL').run(new Date().toISOString(), userId).changes;
369
+ if (n){
370
+ audit(userId, 'user.key_revoked', { by });
371
+ }
372
+ return n > 0;
141
373
  }
142
374
 
143
375
  // Partial update — only the fields present in `fields` are touched, so a
@@ -147,10 +379,10 @@ function createAdminUsersService(db){
147
379
  // silent overwrite.
148
380
  function updateProfile(id, fields){
149
381
  if (fields.username !== undefined){
150
- const owner = getByUsername(fields.username);
151
- if (owner && owner.id !== id){
152
- throw Object.assign(new Error(`Username "${fields.username}" is already taken`), { status: 409 });
153
- }
382
+ checkUsername(fields.username, id);
383
+ }
384
+ if (fields.email !== undefined && fields.email !== ''){
385
+ checkEmail(fields.email, id);
154
386
  }
155
387
  if (fields.theme !== undefined && !THEMES.includes(fields.theme)){
156
388
  throw Object.assign(new Error(`theme must be one of: ${THEMES.join(', ')}`), { status: 400 });
@@ -163,6 +395,7 @@ function createAdminUsersService(db){
163
395
 
164
396
  const columns = {
165
397
  username: fields.username,
398
+ email: fields.email !== undefined ? String(fields.email).trim() : undefined,
166
399
  first_name: fields.firstName,
167
400
  last_name: fields.lastName,
168
401
  avatar_path: fields.avatarPath,
@@ -175,38 +408,57 @@ function createAdminUsersService(db){
175
408
  return getById(id);
176
409
  }
177
410
 
178
- db.prepare(`UPDATE admin_users SET ${set.map(([k]) => `${k} = ?`).join(', ')} WHERE id = ?`).run(
411
+ db.prepare(`UPDATE users SET ${set.map(([k]) => `${k} = ?`).join(', ')} WHERE id = ?`).run(
179
412
  ...set.map(([, v]) => v),
180
413
  id
181
414
  );
415
+ if (fields.username !== undefined){
416
+ db.prepare('UPDATE agents SET name = ? WHERE user_id = ?').run(fields.username, id);
417
+ }
182
418
  return getById(id);
183
419
  }
184
420
 
185
421
  // Remembers one list page's view (size/sort/dir, §10.1) for this user;
186
422
  // stored normalized, so only valid values ever reach the profile.
187
423
  function setListPrefs(id, list, prefs){
188
- const row = db.prepare('SELECT list_prefs FROM admin_users WHERE id = ?').get(id);
424
+ const row = db.prepare('SELECT list_prefs FROM users WHERE id = ?').get(id);
189
425
  if (!row){
190
426
  throw Object.assign(new Error('Unknown user'), { status: 404 });
191
427
  }
192
428
  const all = { ...parseListPrefs(row.list_prefs), [list]: normalizeListPrefs(list, prefs) };
193
- db.prepare('UPDATE admin_users SET list_prefs = ? WHERE id = ?').run(JSON.stringify(all), id);
429
+ db.prepare('UPDATE users SET list_prefs = ? WHERE id = ?').run(JSON.stringify(all), id);
194
430
  return getById(id);
195
431
  }
196
432
 
433
+ // Users page: recent account events (who did what to which user).
434
+ function recentEvents(limit = 30){
435
+ return db.prepare('SELECT * FROM events WHERE entity = \'user\' ORDER BY id DESC LIMIT ?').all(limit)
436
+ .map((e) => ({ ...e, data: e.data ? JSON.parse(e.data) : {} }));
437
+ }
438
+
197
439
  return {
198
440
  getByUsername,
199
441
  getById,
200
442
  count,
443
+ list,
444
+ keyOf,
201
445
  create,
446
+ update,
447
+ setBlocked,
448
+ remove,
202
449
  resetPassword,
450
+ adminResetPassword,
203
451
  changePassword,
204
452
  verify,
205
453
  updateProfile,
206
454
  setListPrefs,
455
+ issueKey,
456
+ revokeKey,
457
+ recentEvents,
207
458
  SESSION_TIMEOUT_OPTIONS_MIN,
208
- THEMES
459
+ THEMES,
460
+ ROLES
209
461
  };
210
462
  }
211
463
 
212
- module.exports = { createAdminUsersService, SESSION_TIMEOUT_OPTIONS_MIN, THEMES };
464
+ module.exports = { createAdminUsersService, SESSION_TIMEOUT_OPTIONS_MIN, THEMES, ROLES, DASHBOARD_ROLES, normalizeRole, roleLabel };
@@ -29,7 +29,7 @@ function createAgentsService(db, { events }){
29
29
  }
30
30
 
31
31
  function list(){
32
- return db.prepare('SELECT id, name, kind, version, update_to, created_at, last_used_at, revoked_at FROM agents ORDER BY created_at DESC').all();
32
+ return db.prepare('SELECT id, name, kind, user_id, version, update_to, created_at, last_used_at, revoked_at FROM agents ORDER BY created_at DESC').all();
33
33
  }
34
34
 
35
35
  function create({ name, kind }){
@@ -34,6 +34,10 @@ const PAGE_SIZES = [10, 25, 50, 100, 'all'],
34
34
  agents: {
35
35
  sorts: ['name', 'kind', 'version', 'created', 'used', 'status'],
36
36
  defaults: { size: DEFAULT_PAGE_SIZE, sort: 'created', dir: 'desc' }
37
+ },
38
+ users: {
39
+ sorts: ['username', 'role', 'status', 'used'],
40
+ defaults: { size: DEFAULT_PAGE_SIZE, sort: 'username', dir: 'asc' }
37
41
  }
38
42
  };
39
43
 
@@ -655,6 +655,17 @@ function createRegistryService(db, { bus, events }){
655
655
  return get(resourceId);
656
656
  }
657
657
 
658
+ // The Client's hw/sw section as it stands: what it reported at its last
659
+ // start, unless a saved revision hasn't reached it yet — then that one.
660
+ // The base for a revision that only changes something else (groups, an
661
+ // Import without a section) and for Export: the saved one alone could be
662
+ // older than the Client's file, and sending it would undo what changed
663
+ // there since.
664
+ function currentSection(r){
665
+ const pending = r.config_desired && r.config_applied_revision < r.config_revision;
666
+ return pending ? r.config_desired : (r.client_config || r.config_desired);
667
+ }
668
+
658
669
  // { revision, type, config[, file] } while the Client hasn't applied the
659
670
  // saved one; `file`: an import's other fields, until it's applied.
660
671
  function pendingConfig(resourceId){
@@ -684,7 +695,7 @@ function createRegistryService(db, { bus, events }){
684
695
  if (!valid){
685
696
  throw Object.assign(new Error(`Can't import this config into ${r.name}: ${errors.join('; ')}`), { status: 400 });
686
697
  }
687
- const desired = section || r.config_desired || r.client_config;
698
+ const desired = section || currentSection(r);
688
699
  if (!desired){
689
700
  throw Object.assign(new Error(`Can't import this config into ${r.name}: it has no "${HW_DEVICES_SECTION}" section, and the Client hasn't reported one`),
690
701
  { status: 400 });
@@ -714,7 +725,7 @@ function createRegistryService(db, { bus, events }){
714
725
  }
715
726
  // Writing top-level fields needs a Client that reports its config file
716
727
  // (the same that Import needs); an older one would drop them.
717
- const desired = r.config_desired || r.client_config;
728
+ const desired = currentSection(r);
718
729
  if (!desired || !r.client_config_file){
719
730
  throw Object.assign(new Error(`${r.name} is too old to take its groups from the dashboard — update its Client first`), { status: 409 });
720
731
  }
@@ -740,7 +751,7 @@ function createRegistryService(db, { bus, events }){
740
751
  }
741
752
  // Re-shared: a file stored from an older Client may still say hw / sw.
742
753
  const base = shareableClientConfigFile(r.client_config_file || { name: r.reported_name || r.name, type: r.type, labels: r.labels }),
743
- section = r.type === 'hw' ? r.config_desired || r.client_config : null;
754
+ section = r.type === 'hw' ? currentSection(r) : null;
744
755
  return { ...base, ...(pendingImport(r) || {}), type: r.type, ...(section ? { [HW_DEVICES_SECTION]: section } : {}) };
745
756
  }
746
757