OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-note commits / 4e33d50

auth: convert admin user-delete to soft-delete (never hard-delete identities)
DELETE /api/admin/users/:id did raw db.del on pubkey/identity/username/user rows — the exact hard-delete that destroyed 28 accounts historically and violates the never-destroy-persistent-state rule. - Add IdentityRegistry.deactivateUser(): revokes every linked identity (which blocks auth via the providers' existing isRevoked checks) and flags the user deactivated, while PRESERVING every record (user/identity/username) for audit and reactivation. Mirrors the existing revokeIdentity/mergeAccounts soft pattern. - Add reactivateUser() + POST /api/admin/users/:id/reactivate to reverse it. - DELETE route now calls deactivateUser (returns {deactivated} not {deleted}). - Hide deactivated/merged accounts from the public /api/users directory and the active user count in /api/admin/stats. Test: deactivateUser preserves records + revokes identity (auth blocked); reactivateUser reverses it. 11/11 registry tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4e33d50eb42b2628774910edf3e63f257867b837
by plankatron <fleeky@gmail.com> · 2026-06-23T20:11:03.000Z

Changed files (3) +131 -15

lib/auth/identity-registry.js +70 -0
……@@ -570,6 +570,76 @@ export class IdentityRegistry {
570570 return identities
571571 }
572572
573 /**
574 * Soft-delete a user. Marks the user deactivated and revokes every linked
575 * identity so it can no longer authenticate (providers reject revoked
576 * identities). NEVER hard-deletes — the user, identity, and username records
577 * are all preserved for audit and reactivation. (Hard `db.del` of identity
578 * rows previously destroyed 28 accounts; identity cleanup must be soft.)
579 * @param {string} userId
580 * @param {Object} options - { by, reason }
581 * @returns {Promise<Object>} The deactivated user
582 */
583 async deactivateUser(userId, options = {}) {
584 const user = await this.getUser(userId)
585 if (!user) throw new Error('User not found')
586 if (user.deactivated) return user
587
588 // Revoke every linked identity — this is what actually blocks auth.
589 for (const identityId of (user.identities || [])) {
590 try {
591 if (!(await this.isRevoked(identityId))) {
592 await this.revokeIdentity(identityId, {
593 reason: options.reason || 'user deactivated',
594 revokedBy: options.by || 'admin'
595 })
596 }
597 } catch (err) {
598 console.error(`[Identity] deactivateUser: failed to revoke ${identityId}:`, err.message)
599 }
600 }
601
602 user.deactivated = true
603 user.deactivatedAt = Date.now()
604 user.deactivatedBy = options.by || 'admin'
605 user.deactivationReason = options.reason || null
606 user.updatedAt = Date.now()
607 await this.db.put(`user:${userId}`, user)
608
609 console.log(`[Identity] Deactivated user ${userId} (by ${user.deactivatedBy})`)
610 return user
611 }
612
613 /**
614 * Reverse deactivateUser: clear the flag and restore the user's identities.
615 * @param {string} userId
616 * @returns {Promise<Object>} The reactivated user
617 */
618 async reactivateUser(userId) {
619 const user = await this.getUser(userId)
620 if (!user) throw new Error('User not found')
621 if (!user.deactivated) return user
622
623 for (const identityId of (user.identities || [])) {
624 try {
625 if (await this.isRevoked(identityId)) await this.restoreIdentity(identityId)
626 } catch (err) {
627 console.error(`[Identity] reactivateUser: failed to restore ${identityId}:`, err.message)
628 }
629 }
630
631 delete user.deactivated
632 delete user.deactivatedAt
633 delete user.deactivatedBy
634 delete user.deactivationReason
635 user.reactivatedAt = Date.now()
636 user.updatedAt = Date.now()
637 await this.db.put(`user:${userId}`, user)
638
639 console.log(`[Identity] Reactivated user ${userId}`)
640 return user
641 }
642
573643 /**
574644 * Merge two user accounts
575645 * Transfers all identities, permissions, and data from secondary to primary
lib/routes/auth.js +20 -15
……@@ -269,24 +269,28 @@ export function registerAuthRoutes (router, server) {
269269 const user = await registry.getUser(targetUserId)
270270 if (!user) throw new ValidationError('User not found')
271271
272 // Remove identity mappings
273 for (const identityId of (user.identities || [])) {
274 const identity = await registry.getIdentity(identityId)
275 if (identity && identity.publicKey) {
276 await registry.db.del(`pubkey:${identity.publicKey}`)
277 }
278 await registry.db.del(`identity:${identityId}`)
279 }
272 // Soft-delete only. Hard `db.del` of identity/user rows destroyed 28
273 // accounts historically — deactivateUser revokes the identities (blocking
274 // auth) and flags the user while preserving every record for audit/restore.
275 await registry.deactivateUser(targetUserId, { by: auth.userId, reason: 'admin deactivation' })
280276
281 // Remove username mapping
282 if (user.username) {
283 await registry.db.del(`username:${user.username}`)
277 json(res, { ok: true, deactivated: targetUserId })
278 })
279
280 // POST /api/admin/users/:userId/reactivate — Reverse a soft-delete (admin only)
281 router.post('/api/admin/users/:userId/reactivate', async (req, res) => {
282 const auth = await requireAuth(req, server)
283 if (!auth.permissions.admin) {
284 json(res, { error: 'Admin permission required' }, 403)
285 return
284286 }
285287
286 // Remove user
287 await registry.db.del(`user:${targetUserId}`)
288 const registry = server.authManager.identityRegistry
289 const user = await registry.getUser(req.params.userId)
290 if (!user) throw new ValidationError('User not found')
288291
289 json(res, { ok: true, deleted: targetUserId })
292 await registry.reactivateUser(req.params.userId)
293 json(res, { ok: true, reactivated: req.params.userId })
290294 })
291295
292296 // POST /api/admin/notebooks/:notebook/transfer — Transfer notebook ownership (admin only)
……@@ -337,7 +341,7 @@ export function registerAuthRoutes (router, server) {
337341 userCount: users.length,
338342 notebookCount: notebooks.length,
339343 noteCount: totalNotes,
340 users: users.filter(u => !u.mergedInto).length,
344 users: users.filter(u => !u.mergedInto && !u.deactivated).length,
341345 publicNotebooks: notebooks.filter(nb => nb.visibility !== 'private' && !nb.encrypted).length,
342346 privateNotebooks: notebooks.filter(nb => nb.visibility === 'private').length
343347 })
……@@ -365,6 +369,7 @@ export function registerAuthRoutes (router, server) {
365369 for await (const entry of registry.db.createReadStream({ gte: 'user:', lt: 'user:\xff' })) {
366370 const u = entry.value
367371 if (!u.username) continue // Only show users who have set a username
372 if (u.deactivated || u.mergedInto) continue // Hide soft-deleted/merged accounts
368373 users.push({
369374 userId: u.userId,
370375 username: u.username,
test/unit/identity-registry.test.js +41 -0
……@@ -162,3 +162,44 @@ test('IdentityRegistry - merge accounts transfers notebooks', async (t) => {
162162 t.ok(result.primary.notebooks.find(r => r.name === 'personal'), 'secondary notebook merged')
163163 t.alike(result.merged.notebooks, [], 'secondary notebooks cleared')
164164})
165
166test('IdentityRegistry - deactivateUser is a soft-delete (preserves records, blocks auth)', async (t) => {
167 const db = new MockRegistry()
168 const reg = new IdentityRegistry(db)
169
170 const { user } = await reg.autoRegister({
171 identityId: 'pk_deact',
172 method: 'web-identity',
173 credentials: { publicKey: 'pk_deact' }
174 })
175
176 await reg.deactivateUser(user.userId, { by: 'admin-1', reason: 'spam' })
177
178 // User record is PRESERVED — never hard-deleted
179 const after = await reg.getUser(user.userId)
180 t.ok(after, 'user record still exists (not hard-deleted)')
181 t.is(after.deactivated, true, 'user flagged deactivated')
182 t.is(after.deactivatedBy, 'admin-1', 'records who deactivated it')
183
184 // Identity is revoked → providers will reject auth; identity row preserved
185 t.ok(await reg.isRevoked('pk_deact'), 'linked identity is revoked (auth blocked)')
186 t.ok(await reg.getIdentity('pk_deact'), 'identity record still exists')
187})
188
189test('IdentityRegistry - reactivateUser reverses a soft-delete', async (t) => {
190 const db = new MockRegistry()
191 const reg = new IdentityRegistry(db)
192
193 const { user } = await reg.autoRegister({
194 identityId: 'pk_react',
195 method: 'web-identity',
196 credentials: { publicKey: 'pk_react' }
197 })
198
199 await reg.deactivateUser(user.userId, { by: 'admin-1' })
200 await reg.reactivateUser(user.userId)
201
202 const after = await reg.getUser(user.userId)
203 t.absent(after.deactivated, 'deactivated flag cleared')
204 t.absent(await reg.isRevoked('pk_react'), 'identity restored (no longer revoked)')
205})