diff --git a/backend/__tests__/service/accountDeletion.test.js b/backend/__tests__/service/accountDeletion.test.js new file mode 100644 index 0000000000..a177873d71 --- /dev/null +++ b/backend/__tests__/service/accountDeletion.test.js @@ -0,0 +1,596 @@ +const express = require('express'); +const request = require('supertest'); +const jwt = require('jsonwebtoken'); +const { createHash } = require('crypto'); +const { setupMongoDb, closeMongoDb, clearMongoDb, setupPgDb, closePgDb, clearPgDb } = require('../utils/testUtils'); +let mockPg; +jest.mock('../../config/db-pg', () => ({ pool: { query: (...args) => mockPg.query(...args) } })); +const mockStripe = { subscriptions: { cancel: jest.fn().mockResolvedValue({ status: 'canceled' }) } }; +jest.mock('../../services/billingService', () => ({ stripe: () => mockStripe })); +const User = require('../../models/User'); +const File = require('../../models/File'); +const Pod = require('../../models/Pod'); +const authController = require('../../controllers/authController'); +const MediaObject = require('../../models/MediaObject'); +const DeviceAuthorization = require('../../models/DeviceAuthorization'); +const OAuthLoginState = require('../../models/OAuthLoginState'); +const AgentCredential = require('../../models/AgentCredential'); +const AgentMemory = require('../../models/AgentMemory'); +const { AgentInstallation } = require('../../models/AgentRegistry'); +const SchedulerLease = require('../../models/SchedulerHeartbeatLease'); +const { issueRuntimeTokenForAgent } = require('../../routes/registry/tokens'); +const agentRuntimeAuth = require('../../middleware/agentRuntimeAuth'); +const { sweepAccountDeletions } = require('../../services/accountDeletionService'); +const AccountDeletion = require('../../models/AccountDeletion'); +const { issueSessionToken } = require('../../services/sessionTokenService'); +const { getObjectStore } = require('../../services/objectStore'); +const oauthController = require('../../controllers/oauthController'); +const auth = require('../../middleware/auth'); +const { socketAuthMiddleware } = require('../../middleware/socketAuth'); +const routes = require('../../routes/users'); +const uploads = require('../../routes/uploads'); +const adminRoutes = require('../../routes/admin/users'); +const password = 'Delete-this-account-123!'; +const makeUser = (fields = {}) => User.create({ username: `person-${Math.random().toString(16).slice(2)}`, + email: `${Math.random()}@example.test`, password, ...fields }); +let ip = 0; +const deletion = (app, token, body = { confirmation: 'DELETE', password }) => request(app) + .delete('/api/users/me').set('cf-connecting-ip', `192.0.2.${++ip}`) + .set('Authorization', `Bearer ${token}`).send(body); +const addFile = async (owner, name, legacy = false) => { + if (!legacy) await getObjectStore().put(name, Buffer.from('private'), 'image/png'); + return File.create({ fileName: name, originalName: name, contentType: 'image/png', size: 7, + uploadedBy: owner._id, ...(legacy ? { data: Buffer.from('legacy') } : {}) }); +}; + +describe('Self-serve account deletion', () => { + let app; + beforeAll(async () => { + process.env.JWT_SECRET = 'account-deletion-service-test'; + await setupMongoDb(); + mockPg = await setupPgDb(); + await mockPg.query(`CREATE TABLE IF NOT EXISTS users (_id VARCHAR(24) PRIMARY KEY, + username VARCHAR(100), profile_picture TEXT, is_bot BOOLEAN DEFAULT false, + created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW())`); + app = express(); app.use(express.json()); + app.use('/api/users', routes); app.use('/api/uploads', uploads); + app.use('/api/admin/users', adminRoutes); + const probeLimit = require('express-rate-limit')({ windowMs: 60000, max: 1000 }); + app.get('/session-probe', probeLimit, auth, (_req, res) => res.sendStatus(204)); + app.post('/session-probe', probeLimit, auth, (_req, res) => res.sendStatus(204)); + const recovery = express.Router(); + recovery.get('/user', probeLimit, auth, authController.getCurrentUser); + recovery.get('/profile', probeLimit, auth, (_req, res) => res.sendStatus(204)); + app.use('/api/auth', recovery); + app.get('/runtime-probe', probeLimit, agentRuntimeAuth, (_req, res) => res.sendStatus(204)); + }); + afterEach(async () => { await clearMongoDb(); await clearPgDb(); await mockPg.query('DELETE FROM users'); }); + afterAll(async () => { await closeMongoDb(); await closePgDb(); }); + + it('deletes own bytes and legacy uploads, revokes credentials, preserves agents and other users', async () => { + const deviceToken = 'cm_delete-device'; + const human = await makeUser({ apiToken: 'cm_delete-api', deviceTokens: [{ + tokenHash: createHash('sha256').update(deviceToken).digest('hex'), label: 'Laptop' }] }); + const other = await makeUser(); + const agent = await makeUser({ isBot: true }); + const sharedPod = await Pod.create({ name: 'Keep shared pod', createdBy: human._id, + members: [human._id, other._id, agent._id] }); + const memory = await AgentMemory.create({ agentName: 'surviving-agent', instanceId: 'default', + sections: { long_term: { content: 'Remember this', visibility: 'private', byteSize: 13 } } }); + const ownInstallation = await AgentInstallation.create({ agentName: 'surviving-agent', + podId: other._id, version: '1.0.0', installedBy: human._id }); + const otherInstallation = await AgentInstallation.create({ agentName: 'surviving-agent', + podId: agent._id, version: '1.0.0', installedBy: other._id }); + const credential = await AgentCredential.create({ tokenHash: 'delete-runtime-hash', kind: 'runtime', + ownerUserId: human._id, agentUserId: agent._id, scopes: ['messages:write'] }); + const otherCredential = await AgentCredential.create({ tokenHash: 'keep-runtime-hash', kind: 'runtime', + ownerUserId: other._id, agentUserId: agent._id, scopes: ['messages:write'] }); + await DeviceAuthorization.create({ deviceCodeHash: 'delete-device', userCodeHash: 'delete-code', + hostname: 'laptop', clientName: 'cli', status: 'authorized', userId: human._id, + expiresAt: new Date(Date.now() + 60000) }); + await addFile(human, 'own.png'); await addFile(human, 'legacy.png', true); + await addFile(other, 'other.png'); await addFile(agent, 'agent.png'); + await mockPg.query('INSERT INTO users (_id, username, profile_picture) VALUES ($1,$2,$3)', + [String(human._id), 'Private display name', '/api/uploads/own.png']); + await mockPg.query('INSERT INTO pods (id,name,type,created_by) VALUES ($1,$2,$3,$4)', + ['123456789012345678901234', 'Shared', 'chat', String(other._id)]); + await mockPg.query('INSERT INTO messages (id,pod_id,user_id,content) VALUES ($1,$2,$3,$4)', + ['1', '123456789012345678901234', String(human._id), 'Shared history']); + await mockPg.query('INSERT INTO pod_members (pod_id,user_id) VALUES ($1,$2)', + ['123456789012345678901234', String(human._id)]); + const token = issueSessionToken(human); + await deletion(app, token).expect(204); + expect(await User.findById(human._id)).toBeNull(); + const retainedPod = await Pod.findById(sharedPod._id); + expect(retainedPod.members.map(String).sort()).toEqual([String(other._id), String(agent._id)].sort()); + expect(String(retainedPod.createdBy)).toBe(String(human._id)); + expect((await mockPg.query('SELECT * FROM pod_members WHERE user_id = $1', [String(human._id)])).rows).toHaveLength(0); + expect(await File.countDocuments({ uploadedBy: human._id })).toBe(0); + expect(await MediaObject.findOne({ key: 'own.png' })).toBeNull(); + expect(await MediaObject.countDocuments()).toBe(2); + expect(await User.findById(agent._id)).not.toBeNull(); + expect((await AgentMemory.findById(memory._id)).sections.long_term.content).toBe('Remember this'); + expect((await AgentInstallation.findById(ownInstallation._id)).status).toBe('paused'); + expect((await AgentInstallation.findById(otherInstallation._id)).status).toBe('active'); + expect(await User.findById(other._id)).not.toBeNull(); + expect((await AgentCredential.findById(credential._id)).status).toBe('revoked'); + expect((await AgentCredential.findById(otherCredential._id)).status).toBe('active'); + expect(await DeviceAuthorization.countDocuments({ userId: human._id })).toBe(0); + const author = (await mockPg.query('SELECT * FROM users WHERE _id = $1', [String(human._id)])).rows[0]; + expect(author.username).toBe('Deleted user'); expect(author.profile_picture).toBeNull(); + expect((await mockPg.query('SELECT content FROM messages')).rows[0].content).toBe('Shared history'); + // A profile mirror or credential mint that was authenticated before delete + // may finish late. The durable receipt reapplies redaction and revocation. + await mockPg.query('UPDATE users SET username = $2 WHERE _id = $1', [String(human._id), 'Late private name']); + await AgentCredential.updateOne({ _id: credential._id }, { $set: { status: 'active' } }); + await sweepAccountDeletions(); + expect((await mockPg.query('SELECT username FROM users WHERE _id = $1', [String(human._id)])).rows[0].username).toBe('Deleted user'); + expect((await AgentCredential.findById(credential._id)).status).toBe('revoked'); + for (const bearer of [token, 'cm_delete-api', deviceToken]) { + await request(app).get('/api/users/profile').set('Authorization', `Bearer ${bearer}`).expect(401); + } + await request(app).get('/api/uploads/own.png').expect(404); + await request(app).get('/api/uploads/legacy.png').expect(404); + await request(app).get('/api/uploads/other.png').expect(200); + }); + + it.each(['agent-room', 'agent-dm'])('keeps the two historical members of a deleted human’s %s', async (type) => { + const human = await makeUser(); + const agent = await makeUser({ isBot: true }); + const pod = await Pod.create({ name: 'Historical DM', type, createdBy: agent._id, + members: [human._id, agent._id], joinPolicy: 'invite-only' }); + await mockPg.query('INSERT INTO pods (id,name,type,created_by) VALUES ($1,$2,$3,$4)', + [String(pod._id), 'Historical DM', type, String(agent._id)]); + for (const member of [human, agent]) { + await mockPg.query('INSERT INTO pod_members (pod_id,user_id) VALUES ($1,$2)', + [String(pod._id), String(member._id)]); + } + const otherOwner = await makeUser(); + const otherInstallation = await AgentInstallation.create({ agentName: 'other-owner-dm-agent', + podId: pod._id, version: '1.0.0', installedBy: otherOwner._id }); + const installation = await AgentInstallation.create({ agentName: 'historical-dm-agent', + podId: pod._id, version: '1.0.0', installedBy: human._id }); + await deletion(app, issueSessionToken(human)).expect(204); + expect((await AgentInstallation.findById(installation._id)).status).toBe('paused'); + expect((await AgentInstallation.findById(otherInstallation._id)).status).toBe('active'); + const expected = [String(human._id), String(agent._id)].sort(); + const mongoMembers = (await Pod.findById(pod._id)).members.map(String).sort(); + const rows = (await mockPg.query('SELECT user_id FROM pod_members WHERE pod_id = $1', [String(pod._id)])).rows; + expect({ mongoMembers, pgMembers: rows.map((row) => row.user_id).sort() }) + .toEqual({ mongoMembers: expected, pgMembers: expected }); + await sweepAccountDeletions(); + expect((await Pod.findById(pod._id)).members.map(String).sort()).toEqual(expected); + expect(await User.findById(human._id)).toBeNull(); + expect(await User.findById(agent._id)).not.toBeNull(); + const DMService = require('../../services/dmService'); + const podController = require('../../controllers/podController'); + const res = { status: jest.fn().mockReturnThis(), json: jest.fn().mockReturnThis() }; + await podController.getPodById({ params: { id: String(pod._id), type }, userId: String(agent._id) }, res); + expect(res.status).not.toHaveBeenCalled(); + const readPod = res.json.mock.calls[0][0]; + const historicalMember = readPod.members.find((member) => String(member._id) === String(human._id)); + expect(historicalMember.username).toBe('Deleted user'); + expect(await DMService.canViewPod(String(agent._id), readPod)).toBe(true); + if (type === 'agent-dm') { + await Pod.create({ name: 'Observer shared pod', createdBy: otherOwner._id, + members: [otherOwner._id, agent._id] }); + expect(await DMService.canViewPod(String(otherOwner._id), readPod)).toBe(true); + expect(await DMService.listPodIdsForListing(String(otherOwner._id))).toContain(String(pod._id)); + } else { + expect(await DMService.canViewPod(String(otherOwner._id), readPod)).toBe(false); + } + const PodContextService = require('../../services/podContextService'); + const context = await PodContextService.getPodContext({ podId: String(pod._id), userId: String(agent._id), skillMode: 'none' }); + expect(context.members).toEqual(expect.arrayContaining([{ name: 'Deleted user', isAgent: false, self: false }])); + }); + + it('requires explicit confirmation, the right password and a browser session', async () => { + const user = await makeUser({ apiToken: 'cm_no-deletion' }); const token = issueSessionToken(user); + await deletion(app, token, { password }).expect(400); + await deletion(app, token, { confirmation: 'DELETE' }).expect(401); + await deletion(app, token, { confirmation: 'DELETE', password: 'wrong' }).expect(401); + await deletion(app, 'cm_no-deletion').expect(403); + const after = await User.findById(user._id).select('+apiToken'); + expect(after).not.toBeNull(); + expect(after.apiToken).toBe('cm_no-deletion'); + expect(after.accountDeletionRequestedAt).toBeUndefined(); + await request(app).get('/session-probe').set('Authorization', `Bearer ${issueSessionToken(user)}`).expect(204); + }); + + it('requires fresh OAuth sign-in, not JWT issue time or refresh', async () => { + const user = await makeUser({ password: undefined, + authProviders: [{ provider: 'github', providerId: 'oauth-delete', linkedAt: new Date() }] }); + const now = Math.floor(Date.now() / 1000); + for (const marker of [undefined, now - 301, now + 5, 'now']) { + const token = jwt.sign({ id: user.id, ...(marker === undefined ? {} : { oauthAuthenticatedAt: marker }) }, + process.env.JWT_SECRET, { expiresIn: 1000 }); + await deletion(app, token, { confirmation: 'DELETE' }).expect(401); + } + await deletion(app, issueSessionToken(user, now - 1, 0), { confirmation: 'DELETE' }).expect(401); + // Real one-time OAuth exchange is the positive witness, not a fabricated claim. + await OAuthLoginState.create({ provider: 'github', state: 'deletion-state', userId: user._id, + exchangeCode: 'deletion-exchange', exchangeExpiresAt: new Date(Date.now() + 60000), + expiresAt: new Date(Date.now() + 60000) }); + let session; + await oauthController.exchangeOAuthCode({ body: { code: 'deletion-exchange' } }, { + json: (body) => { session = body; }, status: () => { throw new Error('Exchange failed'); } }); + expect(jwt.verify(session.token, process.env.JWT_SECRET).oauthAuthenticatedAt).toBeGreaterThanOrEqual(now); + await deletion(app, session.token, { confirmation: 'DELETE' }).expect(204); + }); + + it('keeps failed cleanup retryable and blocks new uploads', async () => { + const user = await makeUser(); const token = issueSessionToken(user); + await addFile(user, 'retry.png'); + jest.spyOn(getObjectStore(), 'delete').mockRejectedValueOnce(new Error('Storage unavailable')); + await deletion(app, token).expect(503); + expect(await File.countDocuments({ uploadedBy: user._id })).toBe(1); + expect((await User.findById(user._id)).accountDeletionRequestedAt).toBeTruthy(); + await request(app).post('/api/uploads').set('Authorization', `Bearer ${token}`) + .attach('image', Buffer.from('late'), 'late.png').expect(403); + await deletion(app, token).expect(204); + expect(await MediaObject.findOne({ key: 'retry.png' })).toBeNull(); + }); + + it('discards an upload that was already in flight', async () => { + const user = await makeUser(); const token = issueSessionToken(user); + const store = getObjectStore(); const originalPut = store.put.bind(store); + jest.spyOn(store, 'put').mockImplementationOnce(async (...args) => { + await deletion(app, token).expect(204); return originalPut(...args); + }); + await request(app).post('/api/uploads').set('Authorization', `Bearer ${token}`) + .attach('image', Buffer.from('late bytes'), 'late.png').expect(403); + expect(await MediaObject.countDocuments()).toBe(0); + expect(await File.countDocuments()).toBe(0); + }); + + it('does not let anonymous deletion attempts exhaust a proxy-wide bucket', async () => { + for (let attempt = 0; attempt < 121; attempt += 1) { + const response = await request(app).delete('/api/users/me').send({ confirmation: 'DELETE' }); + expect([401, 429]).toContain(response.status); + } + const user = await makeUser(); + await deletion(app, issueSessionToken(user), {}).expect(400); + }); + + it('limits deletion by account despite rotating JWTs and forwarding headers', async () => { + const user = await makeUser(); + const tokens = [issueSessionToken(user), issueSessionToken(user, undefined, undefined, 1)]; + for (let attempt = 0; attempt < 10; attempt += 1) { + await deletion(app, tokens[attempt % 2], {}).expect(400); + } + await deletion(app, tokens[0], {}).expect(429); + expect(await User.findById(user._id)).not.toBeNull(); + expect(await AccountDeletion.countDocuments()).toBe(0); + }); + + it('recovers a stale billing preflight claim and refuses an active one', async () => { + const user = await makeUser({ accountDeletionRequestedAt: new Date() }); + const token = issueSessionToken(user); + await deletion(app, token).expect(409); + await User.updateOne({ _id: user._id }, { + $set: { accountDeletionRequestedAt: new Date(Date.now() - 301000) }, + }); + await deletion(app, token).expect(204); + }); + + it('recovers a failed receipt write without relying on a browser retry', async () => { + const user = await makeUser({ billing: { subscriptionId: 'sub_receipt', subscriptionStatus: 'active' } }); + await addFile(user, 'stranded.png'); + jest.spyOn(AccountDeletion, 'updateOne').mockRejectedValueOnce(new Error('Receipt store unavailable')); + await deletion(app, issueSessionToken(user)).expect(503); + expect(await AccountDeletion.countDocuments()).toBe(0); + expect((await User.findById(user._id)).accountDeletionRequestedAt).toBeDefined(); + await sweepAccountDeletions(); + expect(await AccountDeletion.countDocuments()).toBe(0); // Active preflight is left alone. + await User.updateOne({ _id: user._id }, { + $set: { accountDeletionRequestedAt: new Date(Date.now() - 301000) }, + }); + await sweepAccountDeletions(); + expect(await AccountDeletion.countDocuments()).toBe(1); + expect(await File.countDocuments({ uploadedBy: user._id })).toBe(0); + expect(await MediaObject.findOne({ key: 'stranded.png' })).toBeNull(); + await deletion(app, issueSessionToken(user)).expect(204); + }); + + it('leaves the account and uploads intact if subscription cancellation fails', async () => { + const user = await makeUser({ apiToken: 'cm_keep-on-billing-failure', + billing: { subscriptionId: 'sub_fail', subscriptionStatus: 'active' } }); + await addFile(user, 'billing.png'); + mockStripe.subscriptions.cancel.mockRejectedValueOnce(new Error('Provider unavailable')); + await deletion(app, issueSessionToken(user)).expect(503); + const after = await User.findById(user._id).select('+apiToken'); + expect(after.apiToken).toBe('cm_keep-on-billing-failure'); + expect(after.accountDeletionRequestedAt).toBeUndefined(); + await request(app).get('/session-probe').set('Authorization', `Bearer ${issueSessionToken(user)}`).expect(204); + expect(await MediaObject.findOne({ key: 'billing.png' })).not.toBeNull(); + expect(await File.countDocuments({ uploadedBy: user._id })).toBe(1); + }); + + it('blocks ordinary HTTP and socket access after failed cleanup but permits reauthenticated deletion retry', async () => { + const user = await makeUser(); const token = issueSessionToken(user); + await request(app).get('/session-probe').set('Authorization', `Bearer ${token}`).expect(204); + jest.spyOn(mockPg, 'query').mockRejectedValueOnce(new Error('PG unavailable')); + await deletion(app, token).expect(503); + await request(app).get('/session-probe').set('Authorization', `Bearer ${token}`).expect(403); + await request(app).post('/session-probe').set('Authorization', `Bearer ${token}`).expect(403); + await request(app).get('/api/auth/profile').set('Authorization', `Bearer ${token}`).expect(403); + const bootstrap = await request(app).get('/api/auth/user').set('Authorization', `Bearer ${token}`).expect(200); + expect(Object.keys(bootstrap.body).sort()).toEqual(['_id', 'username', 'email', 'role', 'accountDeletionPending'].sort()); + expect(bootstrap.body.accountDeletionPending).toBe(true); + const next = jest.fn(); + await socketAuthMiddleware({ handshake: { auth: { token } } }, next); + expect(next).toHaveBeenCalledWith(expect.any(Error)); + await deletion(app, token, { confirmation: 'DELETE', password: 'wrong' }).expect(401); + await deletion(app, token).expect(204); + }); + + it('keeps a PG failure retryable and only removes the human after anonymization succeeds', async () => { + const user = await makeUser(); const token = issueSessionToken(user); + jest.spyOn(mockPg, 'query').mockRejectedValueOnce(new Error('PG unavailable')); + await deletion(app, token).expect(503); + expect(await User.findById(user._id)).not.toBeNull(); + await deletion(app, token).expect(204); + expect(await User.findById(user._id)).toBeNull(); + }); + + it.each([false, true])('retries late-upload cleanup after User deletion even if File save fails (%s)', async (metadataFails) => { + const user = await makeUser(); const token = issueSessionToken(user); + const other = await makeUser(); await addFile(other, 'keep.png'); + const store = getObjectStore(); const originalPut = store.put.bind(store); + jest.spyOn(store, 'put').mockImplementationOnce(async (...args) => { + await deletion(app, token).expect(204); + await originalPut(...args); + if (metadataFails) jest.spyOn(File.prototype, 'save').mockRejectedValueOnce(new Error('Metadata unavailable')); + else jest.spyOn(store, 'delete').mockRejectedValueOnce(new Error('Late storage delete unavailable')); + }); + await request(app).post('/api/uploads').set('Authorization', `Bearer ${token}`) + .attach('image', Buffer.from('late bytes'), 'late.png').expect(500); + expect(await User.findById(user._id)).toBeNull(); + expect(await AccountDeletion.findById(user._id)).not.toBeNull(); + expect(await MediaObject.countDocuments()).toBe(2); + if (metadataFails) expect(await File.countDocuments({ uploadedBy: user._id })).toBe(0); + await sweepAccountDeletions(); + expect(await MediaObject.countDocuments()).toBe(1); + expect(await MediaObject.findOne({ key: 'keep.png' })).not.toBeNull(); + expect(await File.countDocuments({ uploadedBy: user._id })).toBe(0); + // Repeated and overlapping sweeps are safe after their data is gone. + await Promise.all([sweepAccountDeletions(), sweepAccountDeletions()]); + expect(await MediaObject.countDocuments()).toBe(1); + }); + + it('refuses both concurrent admin deletions without changing accounts or creating cleanup intents', async () => { + const a = await makeUser({ role: 'admin' }); const b = await makeUser({ role: 'admin' }); + const results = await Promise.all([deletion(app, issueSessionToken(a)), deletion(app, issueSessionToken(b))]); + expect(results.map((result) => result.status)).toEqual([409, 409]); + expect(results.map((result) => result.body.code)).toEqual(['ADMIN_HANDOFF_REQUIRED', 'ADMIN_HANDOFF_REQUIRED']); + expect(await User.countDocuments({ role: 'admin' })).toBe(2); + expect(await AccountDeletion.countDocuments()).toBe(0); + expect((await User.findById(a._id)).accountDeletionRequestedAt).toBeUndefined(); + expect((await User.findById(b._id)).accountDeletionRequestedAt).toBeUndefined(); + }); + + it('allows deletion after another administrator hands off the role', async () => { + const user = await makeUser({ role: 'admin' }); const keeper = await makeUser({ role: 'admin' }); + await request(app).patch(`/api/admin/users/${user.id}/role`) + .set('Authorization', `Bearer ${issueSessionToken(keeper)}`).send({ role: 'user' }).expect(200); + expect((await User.findById(user._id)).role).toBe('user'); + await deletion(app, issueSessionToken(user)).expect(204); + expect(await User.findById(user._id)).toBeNull(); + expect((await User.findById(keeper._id)).role).toBe('admin'); + }); + + it('refuses deletion if a promotion wins before the conditional deletion marker', async () => { + const user = await makeUser({ billing: { subscriptionId: 'sub_promoted', subscriptionStatus: 'active' } }); + await addFile(user, 'promotion.png'); + mockStripe.subscriptions.cancel.mockClear(); + const updateOne = User.updateOne.bind(User); + jest.spyOn(User, 'updateOne').mockImplementation((filter, update, ...rest) => { + if (update.$set?.accountDeletionRequestedAt) return (async () => { + await updateOne({ _id: user._id }, { $set: { role: 'admin' } }); + return updateOne(filter, update, ...rest); + })(); + return updateOne(filter, update, ...rest); + }); + await deletion(app, issueSessionToken(user)).expect(409); + expect((await User.findById(user._id)).role).toBe('admin'); + expect(mockStripe.subscriptions.cancel).not.toHaveBeenCalled(); + expect(await File.countDocuments({ uploadedBy: user._id })).toBe(1); + expect(await AccountDeletion.countDocuments()).toBe(0); + }); + + it('composes the admin lock with promotion winning before deletion claims its marker', async () => { + const admin = await makeUser({ role: 'admin' }); + const user = await makeUser({ billing: { subscriptionId: 'sub_role_race', subscriptionStatus: 'active' } }); + const update = User.updateOne.bind(User); + let promoted = false; + jest.spyOn(User, 'updateOne').mockImplementation((filter, mutation, ...rest) => { + if (!promoted && mutation.$set?.accountDeletionRequestedAt) return (async () => { + promoted = true; + await request(app).patch(`/api/admin/users/${user.id}/role`) + .set('Authorization', `Bearer ${issueSessionToken(admin)}`).send({ role: 'admin' }).expect(200); + return update(filter, mutation, ...rest); + })(); + return update(filter, mutation, ...rest); + }); + mockStripe.subscriptions.cancel.mockClear(); + await deletion(app, issueSessionToken(user)).expect(409); + const remaining = await User.findById(user.id); + expect(remaining.role).toBe('admin'); + expect(remaining.accountDeletionRequestedAt).toBeUndefined(); + expect(mockStripe.subscriptions.cancel).not.toHaveBeenCalled(); + expect(await AccountDeletion.countDocuments()).toBe(0); + }); + + it('composes the admin lock with deletion winning after the role target was read', async () => { + const admin = await makeUser({ role: 'admin' }); const user = await makeUser(); + const update = User.findOneAndUpdate.bind(User); + let claimed = false; + jest.spyOn(User, 'findOneAndUpdate').mockImplementation((filter, mutation, ...rest) => { + if (!claimed && mutation.$set?.role === 'admin') return (async () => { + claimed = true; + jest.spyOn(mockPg, 'query').mockRejectedValueOnce(new Error('PG unavailable')); + await deletion(app, issueSessionToken(user)).expect(503); + return update(filter, mutation, ...rest); + })(); + return update(filter, mutation, ...rest); + }); + await request(app).patch(`/api/admin/users/${user.id}/role`) + .set('Authorization', `Bearer ${issueSessionToken(admin)}`).send({ role: 'admin' }).expect(409); + const remaining = await User.findById(user.id); + expect(remaining.role).toBe('user'); + expect(remaining.accountDeletionRequestedAt).toBeTruthy(); + const Lock = require('../../models/AdminUserMutationLock'); + expect(await Lock.countDocuments()).toBe(0); + }); + + it('refuses an admin role change after deletion has marked the account', async () => { + const admin = await makeUser({ role: 'admin' }); + const user = await makeUser({ accountDeletionRequestedAt: new Date() }); + await request(app).patch(`/api/admin/users/${user.id}/role`) + .set('Authorization', `Bearer ${issueSessionToken(admin)}`).send({ role: 'admin' }).expect(409); + expect((await User.findById(user._id)).role).toBe('user'); + }); + + it('discards an in-flight upload when PG failure leaves the human marked for deletion', async () => { + const user = await makeUser(); const token = issueSessionToken(user); + const store = getObjectStore(); const put = store.put.bind(store); + jest.spyOn(store, 'put').mockImplementationOnce(async (...args) => { + jest.spyOn(mockPg, 'query').mockRejectedValueOnce(new Error('PG unavailable')); + await deletion(app, token).expect(503); + return put(...args); + }); + await request(app).post('/api/uploads').set('Authorization', `Bearer ${token}`) + .attach('image', Buffer.from('late'), 'late.png').expect(403); + expect((await User.findById(user._id)).accountDeletionRequestedAt).toBeTruthy(); + expect(await MediaObject.countDocuments()).toBe(0); + expect(await File.countDocuments({ uploadedBy: user._id })).toBe(0); + }); + + it('continues past one failed receipt and reports aggregate failure after cleaning the next', async () => { + const a = await makeUser(); const b = await makeUser(); + await AccountDeletion.create([{ _id: a._id }, { _id: b._id }]); + await addFile(a, 'fail.png'); await addFile(b, 'clean.png'); + const remove = getObjectStore().delete.bind(getObjectStore()); + jest.spyOn(getObjectStore(), 'delete').mockImplementation(async (key) => { + if (key === 'fail.png') throw new Error('One object unavailable'); + return remove(key); + }); + await expect(sweepAccountDeletions()).rejects.toThrow('Account deletion cleanup failed'); + expect(await File.findOne({ fileName: 'fail.png' })).not.toBeNull(); + expect(await File.findOne({ fileName: 'clean.png' })).toBeNull(); + expect(await MediaObject.findOne({ key: 'clean.png' })).toBeNull(); + expect((await AccountDeletion.findById(b._id)).lastSweptAt).toBeTruthy(); + expect(await SchedulerLease.findById('account-deletion-sweep')).toBeNull(); + }); + + it('skips a sweep held by another worker and recovers an expired lease', async () => { + const user = await makeUser(); await AccountDeletion.create({ _id: user._id }); + await addFile(user, 'lease.png'); + await SchedulerLease.create({ _id: 'account-deletion-sweep', ownerId: 'another-worker', + expiresAt: new Date(Date.now() + 120000) }); + await sweepAccountDeletions(); + expect(await MediaObject.findOne({ key: 'lease.png' })).not.toBeNull(); + await SchedulerLease.updateOne({ _id: 'account-deletion-sweep' }, { $set: { expiresAt: new Date(0) } }); + await sweepAccountDeletions(); + expect(await MediaObject.findOne({ key: 'lease.png' })).toBeNull(); + }); + + it('bounds each sweep and fairly rotates receipts without expiring late-write intent', async () => { + const ids = Array.from({ length: 101 }, () => new User()._id); + await AccountDeletion.insertMany(ids.map((_id) => ({ _id }))); + await sweepAccountDeletions(); + expect(await AccountDeletion.countDocuments({ lastSweptAt: { $exists: true } })).toBe(100); + expect(await AccountDeletion.countDocuments()).toBe(101); + await sweepAccountDeletions(); + expect(await AccountDeletion.countDocuments({ lastSweptAt: { $exists: true } })).toBe(101); + }); + + it.each([false, true])('refuses issuance and backfill for a marked owner (%s)', async (existing) => { + const owner = await makeUser({ accountDeletionRequestedAt: new Date() }); + const agent = await makeUser({ isBot: true, agentRuntimeTokens: existing ? [{ tokenHash: 'old-hash', label: 'old' }] : [] }); + await expect(issueRuntimeTokenForAgent(agent, 'key', null, { ownerUserId: owner._id })) + .rejects.toMatchObject({ code: 'account_deleting' }); + expect(await AgentCredential.countDocuments()).toBe(0); + }); + + it.each([false, true])('revokes a token minted or backfilled after deletion completed (%s)', async (existing) => { + const owner = await makeUser(); + const agent = await makeUser({ isBot: true, agentRuntimeTokens: existing ? [{ tokenHash: 'late-old-hash', label: 'old' }] : [] }); + let deletionStatus; + const method = existing ? 'updateOne' : 'create'; + const write = AgentCredential[method].bind(AgentCredential); + jest.spyOn(AgentCredential, method).mockImplementationOnce(async (...args) => { + deletionStatus = (await deletion(app, issueSessionToken(owner))).status; + return write(...args); + }); + const outcome = await issueRuntimeTokenForAgent(agent, 'key', null, { ownerUserId: owner._id }).catch((error) => error); + expect(deletionStatus).toBe(204); + expect(outcome).toMatchObject({ code: 'account_deleting' }); + expect(await AgentCredential.countDocuments({ ownerUserId: owner._id, status: 'active' })).toBe(0); + expect((await User.findById(agent._id)).agentRuntimeTokens).toHaveLength(0); + }); + + it('cleans a late legacy reprovision mint even when no issuer ledger was written', async () => { + const owner = await makeUser(); const agent = await makeUser({ isBot: true }); + const installation = await AgentInstallation.create({ agentName: agent.username, podId: owner._id, + version: '1.0.0', installedBy: owner._id }); + const save = agent.save.bind(agent); + jest.spyOn(agent, 'save').mockImplementationOnce(async () => { + await deletion(app, issueSessionToken(owner)).expect(204); + return save(); + }); + await expect(issueRuntimeTokenForAgent(agent, 'key', installation)) + .rejects.toMatchObject({ code: 'account_deleting' }); + expect(await AgentCredential.countDocuments()).toBe(0); + expect((await User.findById(agent._id)).agentRuntimeTokens).toHaveLength(0); + expect((await AgentInstallation.findById(installation._id)).runtimeTokens).toHaveLength(0); + expect((await AgentInstallation.findById(installation._id)).status).toBe('paused'); + }); + + it('a late backfill refusal preserves an existing token owned by someone else', async () => { + const owner = await makeUser(); const other = await makeUser(); + const agent = await makeUser({ isBot: true, agentRuntimeTokens: [{ tokenHash: 'shared-old-hash', label: 'other key' }] }); + const credential = await AgentCredential.create({ tokenHash: 'shared-old-hash', kind: 'runtime', + ownerUserId: other._id, agentUserId: agent._id }); + let deletionStatus; + const write = AgentCredential.updateOne.bind(AgentCredential); + jest.spyOn(AgentCredential, 'updateOne').mockImplementationOnce(async (...args) => { + deletionStatus = (await deletion(app, issueSessionToken(owner))).status; + return write(...args); + }); + const outcome = await issueRuntimeTokenForAgent(agent, 'key', null, { ownerUserId: owner._id }).catch((error) => error); + expect(deletionStatus).toBe(204); + expect(outcome).toMatchObject({ code: 'account_deleting' }); + expect((await AgentCredential.findById(credential._id)).status).toBe('active'); + expect((await User.findById(agent._id)).agentRuntimeTokens).toHaveLength(1); + }); + + it('rejects an active credential while its owner is being deleted, before any later sweep', async () => { + const owner = await makeUser(); + const raw = 'cm_agent_deletion-fence-test'; + const tokenHash = createHash('sha256').update(raw).digest('hex'); + const agent = await makeUser({ isBot: true, botMetadata: { agentName: 'deletion-runtime-probe', instanceId: 'default' }, + agentRuntimeTokens: [{ tokenHash, label: 'probe', lastUsedAt: new Date() }] }); + await AgentInstallation.create({ agentName: 'deletion-runtime-probe', instanceId: 'default', podId: owner._id, + version: '1.0.0', installedBy: owner._id }); + await AgentCredential.create({ tokenHash, kind: 'runtime', ownerUserId: owner._id, agentUserId: agent._id }); + // A viable credential is essential: an incomplete identity would return + // 401 even with the deletion fence removed and could not pin this guard. + await request(app).get('/runtime-probe').set('Authorization', `Bearer ${raw}`).expect(204); + await AccountDeletion.create({ _id: owner._id }); + await request(app).get('/runtime-probe').set('Authorization', `Bearer ${raw}`).expect(401); + expect(await AgentCredential.countDocuments({ status: 'active' })).toBe(1); + }); + + it('protects the last administrator', async () => { + const user = await makeUser({ role: 'admin' }); + await deletion(app, issueSessionToken(user)).expect(409); + expect(await User.findById(user._id)).not.toBeNull(); + }); + + it('cancels a subscription before deleting its billing linkage', async () => { + const user = await makeUser({ billing: { subscriptionId: 'sub_delete', subscriptionStatus: 'active' } }); + await deletion(app, issueSessionToken(user)).expect(204); + expect(mockStripe.subscriptions.cancel).toHaveBeenCalledWith('sub_delete', {}, { idempotencyKey: `account-deletion-${user.id}-sub_delete`, timeout: 30000, maxNetworkRetries: 0 }); + }); +}); diff --git a/backend/__tests__/service/adminRoleConcurrency.test.js b/backend/__tests__/service/adminRoleConcurrency.test.js index f8e1c1115d..dc721d9f35 100644 --- a/backend/__tests__/service/adminRoleConcurrency.test.js +++ b/backend/__tests__/service/adminRoleConcurrency.test.js @@ -100,7 +100,8 @@ describe('Administrator role concurrency', () => { const [alice, bob] = await Promise.all([makeAdmin(`write-${kind}-alice`), makeAdmin(`write-${kind}-bob`)]); const Lock = require('../../models/AdminUserMutationLock'); if (kind === 'ban') await User.updateOne({ _id: bob._id }, { $set: { role: 'user' } }); - const write = jest.spyOn(User.prototype, kind === 'delete' ? 'deleteOne' : 'save') + const write = (kind === 'role' ? jest.spyOn(User, 'findOneAndUpdate') + : jest.spyOn(User.prototype, kind === 'delete' ? 'deleteOne' : 'save')) .mockRejectedValueOnce(new Error('write outcome unknown')); try { const pending = kind === 'delete' ? request(app).delete(`/api/admin/users/${bob._id}`) @@ -131,12 +132,12 @@ describe('Administrator role concurrency', () => { const bob = await User.create({ username: 'promotion-bob', email: 'promotion-bob@example.test', password: 'Role-concurrency-test-123!', role: 'user' }); const writing = deferred(); const resume = deferred(); - const save = User.prototype.save; - jest.spyOn(User.prototype, 'save').mockImplementation(async function (...args) { - if (String(this._id) === String(bob._id) && this.role === 'admin') { + const update = User.findOneAndUpdate.bind(User); + jest.spyOn(User, 'findOneAndUpdate').mockImplementation(async (...args) => { + if (String(args[0]._id) === String(bob._id) && args[1].$set?.role === 'admin') { writing.resolve(); await resume.promise; } - return save.apply(this, args); + return update(...args); }); const first = request(app).patch(`/api/admin/users/${bob._id}/role`) .set('Authorization', `Bearer ${issueSessionToken(alice)}`).send({ role: 'admin' }).then((res) => res); diff --git a/backend/__tests__/unit/controllers/podController.test.js b/backend/__tests__/unit/controllers/podController.test.js index 6e1e3b6ea0..46494d5b99 100644 --- a/backend/__tests__/unit/controllers/podController.test.js +++ b/backend/__tests__/unit/controllers/podController.test.js @@ -120,7 +120,7 @@ describe('podController', () => { expect(save).toHaveBeenCalled(); expect(savedPod.populate).toHaveBeenCalledWith('createdBy', 'username profilePicture'); - expect(savedPod.populate).toHaveBeenCalledWith('members', 'username profilePicture isBot'); + expect(savedPod.populate).toHaveBeenCalledWith(expect.objectContaining({ path: 'members', select: 'username profilePicture isBot' })); expect(AgentInstallation.install).toHaveBeenCalledWith('commonly-bot', 'p1', expect.objectContaining({ installedBy: 'creator', instanceId: 'default', @@ -290,7 +290,7 @@ describe('podController', () => { expect(pod.members).toEqual(['creator']); expect(pod.save).toHaveBeenCalled(); expect(pod.populate).toHaveBeenCalledWith('createdBy', 'username profilePicture'); - expect(pod.populate).toHaveBeenCalledWith('members', 'username profilePicture isBot'); + expect(pod.populate).toHaveBeenCalledWith(expect.objectContaining({ path: 'members', select: 'username profilePicture isBot' })); expect(res.json).toHaveBeenCalledWith(pod); }); diff --git a/backend/__tests__/unit/middleware/agentRuntimeAuth.test.js b/backend/__tests__/unit/middleware/agentRuntimeAuth.test.js index f0598a3fe6..ee7be69a2b 100644 --- a/backend/__tests__/unit/middleware/agentRuntimeAuth.test.js +++ b/backend/__tests__/unit/middleware/agentRuntimeAuth.test.js @@ -1,3 +1,6 @@ +// These unit cases isolate runtime identity behavior. The deletion fence is +// exercised with real models in service/accountDeletion.test.js. +jest.mock('../../../services/accountDeletionState', () => ({ isAccountDeleting: jest.fn().mockResolvedValue(false) })); // agentRuntimeAuth path 2 (legacy installation-token) needs to surface ALL // active AgentInstallation rows for the same (agentName, instanceId), not // just the one whose runtimeTokens.tokenHash matches. Otherwise the /events diff --git a/backend/__tests__/unit/middleware/ipRateLimit.test.js b/backend/__tests__/unit/middleware/ipRateLimit.test.js index 768dc9b9b3..73a9c7e4d7 100644 --- a/backend/__tests__/unit/middleware/ipRateLimit.test.js +++ b/backend/__tests__/unit/middleware/ipRateLimit.test.js @@ -30,3 +30,22 @@ describe('cloudflareIpRateLimitKeyGenerator', () => { expect(key).toBe('anon'); }); }); + +const { authenticatedUserRateLimitKeyGenerator } = require('../../../middleware/ipRateLimit'); + +describe('account mutation quota keys', () => { + it('uses the authenticated account rather than bearer or forwarding headers', () => { + expect(authenticatedUserRateLimitKeyGenerator({ userId: 'one', headers: { authorization: 'rotated' } })) + .toBe('user:one'); + expect(authenticatedUserRateLimitKeyGenerator({ userId: 'two' })).toBe('user:two'); + }); +}); + +const { presentedCredentialRateLimitKeyGenerator } = require('../../../middleware/ipRateLimit'); +it('isolates anonymous traffic from credential buckets without retaining bearer text', () => { + const key = (authorization) => presentedCredentialRateLimitKeyGenerator({ header: (name) => name === 'Authorization' ? authorization : undefined }); + expect(key()).toBe('no-credential'); + expect(key('Bearer first-secret')).not.toBe(key()); + expect(key('Bearer first-secret')).not.toBe(key('Bearer second-secret')); + expect(key('Bearer first-secret')).not.toContain('first-secret'); +}); diff --git a/backend/__tests__/unit/middleware/rateLimitIpKeySeparation.test.js b/backend/__tests__/unit/middleware/rateLimitIpKeySeparation.test.js index 471b8e0839..83707eecda 100644 --- a/backend/__tests__/unit/middleware/rateLimitIpKeySeparation.test.js +++ b/backend/__tests__/unit/middleware/rateLimitIpKeySeparation.test.js @@ -1,13 +1,15 @@ /** * TASK-126 — behavioural probe: no rate limiter mounted in the app may put two - * callers that differ only in `cf-connecting-ip` into one bucket. + * callers that differ in the configured identity into one bucket. Existing + * CF policies vary only cf-connecting-ip; explicit account-deletion/upload + * policies vary only the presented credential or trusted user id. + * The new policies are pinned to their exact function identities and routes. * * Why each walked instance is re-mounted on a stub instead of being exercised * through the loaded app — all four measured, not assumed: - * - Every entry in the loaded router stack is a Layer whose `handle.name` is - * `''` and whose `entry.name` is ``, for middleware generally and - * for limiters specifically. Identity (`resetKey`/`getKey` own properties) - * is the only census instrument; anything name-keyed mis-attributes. + * - Function names do not identify limiters. Express may wrap a limiter + * in a new function while copying resetKey/getKey. The router census uses + * those own properties; policy capture keys on the unchanged getKey method. * - On a route carrying two limiters both write `RateLimit-Remaining` and the * last one that runs wins. Proved on `POST /api/webhooks/groupme/:integrationId`: * the 3000-max IP limiter reported `remaining` 599/598/597 (the 600-max @@ -18,9 +20,10 @@ * `profileWriteUserLimit`) never runs for a header-free request. * - Three instances never answer at all through the app: their handlers need * Mongo and unit mode runs without it. - * On the stub there are no params and no auth header, so every key function - * takes its IP branch (each one falls back to the Cloudflare-aware helper), and - * the 204 terminal answers for all of them — no exemption list, no database. + * Existing policies receive no params, auth header or user id, so their IP + * branch remains the CF probe. Explicit new policies receive a presented + * credential or authenticated user id. Every limiter still runs + * a counted A,A,B sequence; no instance is exempted and no database is needed. * * The A,A,B sequence is deliberate: two requests cannot separate "different * buckets" from "not counting at all", so A2 is the instrument's own control. @@ -31,12 +34,31 @@ * everything handed to it would pass all 84 and establish nothing. */ /* eslint-disable import/no-unresolved, import/extensions */ +// Express wraps middleware functions, but copies getKey unchanged. Capture +// policy by that store method while retaining the real limiter and store. New +// Authenticated-user quotas must be measured on their declared identity dimension. +const mockLimiterKeys = new WeakMap(); +jest.mock('express-rate-limit', () => { + const actual = jest.requireActual('express-rate-limit'); + const wrapped = (options) => { + const instance = actual(options); + mockLimiterKeys.set(instance.getKey, options?.keyGenerator); + return instance; + }; + Object.assign(wrapped, actual); + wrapped.rateLimit = wrapped; + wrapped.default = wrapped; + return wrapped; +}); + const fs = require('fs'); const express = require('express'); const request = require('supertest'); const rateLimit = require('express-rate-limit'); const { ipKeyGenerator } = require('express-rate-limit'); -const { cloudflareIpRateLimitKeyGenerator } = require('../../../middleware/ipRateLimit'); +const { + cloudflareIpRateLimitKeyGenerator, presentedCredentialRateLimitKeyGenerator, authenticatedUserRateLimitKeyGenerator, +} = require('../../../middleware/ipRateLimit'); // Captured at MODULE LOAD, before any test can flip it. A baseline re-read inside // a test moves with a leak instead of catching it: measured, with the per-request @@ -110,9 +132,13 @@ const remainingOf = (res) => { return m ? Number(m[1]) : null; }; -const single = (instance) => { +const single = (instance, dimension = 'cf') => { const stub = express(); - stub.all('/', instance, (req, res) => { + stub.all('/', (req, _res, next) => { + if (dimension === 'user') req.userId = req.get('x-probe-identity'); + if (dimension === 'credential') req.headers.authorization = `Bearer ${req.get('x-probe-identity')}`; + next(); + }, instance, (req, res) => { if (typeof req.rateLimit?.remaining === 'number') { res.set('x-probe-remaining', String(req.rateLimit.remaining)); } @@ -121,12 +147,12 @@ const single = (instance) => { return stub; }; -const hit = async (stub, ip) => { +const hit = async (stub, ip, identity = ip) => { const entry = process.env.NODE_ENV; let outcome; try { process.env.NODE_ENV = 'development'; - const res = await request(stub).get('/').set('cf-connecting-ip', ip) + const res = await request(stub).get('/').set('cf-connecting-ip', ip).set('x-probe-identity', identity) .timeout({ response: 5000, deadline: 8000 }); outcome = { remaining: remainingOf(res), status: res.status }; } catch (err) { @@ -139,13 +165,13 @@ const hit = async (stub, ip) => { return outcome; }; -const observe = async (instance, tag) => { +const observe = async (instance, tag, dimension = 'cf') => { const a = `198.51.100.${tag}`; const b = `203.0.113.${tag}`; - const stub = single(instance); - const first = await hit(stub, a); - const second = await hit(stub, a); - const other = await hit(stub, b); + const stub = single(instance, dimension); + const first = await hit(stub, a, a); + const second = await hit(stub, a, a); + const other = await hit(stub, dimension === 'cf' ? b : a, b); return { r1: first.remaining, r2: second.remaining, r3: other.remaining, status: first.status, error: first.error, }; @@ -179,6 +205,14 @@ describe('TASK-126 ip key separation', () => { expect(distinct.size).toBe(walked.length); }); + it('pins the explicit credential/user policies without exempting them from counting', () => { + for (const key of [presentedCredentialRateLimitKeyGenerator, authenticatedUserRateLimitKeyGenerator]) { + expect(walked.filter((w) => mockLimiterKeys.get(w.instance.getKey) === key) + .map((w) => `${w.method.toUpperCase()} ${w.path}`).sort()) + .toEqual(['DELETE /api/users/me', 'POST /api/uploads/']); + } + }); + it('negative control: the instrument catches a limiter keyed the way the swapped sites were', async () => { // The defect's own spelling, not a cousin of it (vera 73704): the 21 sites // keyed `req.ip ? ipKeyGenerator(req.ip) : 'anon'`. A bare `req.ip` collapses @@ -226,14 +260,11 @@ describe('TASK-126 ip key separation', () => { expect(rec.r3).toBe(rec.r1); }); - // What this measures (wren 73671, ruling (b)), not what it might be read as: - // a request with no credential, no param and no body — built from nothing and - // carrying only `cf-connecting-ip` — is bucketed per `cf-connecting-ip` on - // every instance. That is stricter than a per-arm check: a limiter with no IP - // arm at all keys such a request on a CONSTANT, one bucket for every anonymous - // caller, which is the same defect as raw `req.ip` behind cloudflared — and it - // collapses A and B here exactly the same way. - it('every walked instance counts and separates; none collapses', async () => { + // Legacy policies keep the original anonymous CF-header probe. New policies + // use their presented-credential or authenticated-user dimension while + // holding the CF header constant. Pinning their routes prevents reclassifying + // a legacy raw-req.ip regression as an intentional per-user policy. + it('every walked instance counts and separates its configured identity; none collapses', async () => { const results = []; // Serial, not `Promise.all` (wren 73692): with 84 chains started in one tick, // only the first reads the suite's NODE_ENV; the other 83 read the @@ -246,7 +277,10 @@ describe('TASK-126 ip key separation', () => { purpose: the NODE_ENV restore of one iteration must complete before the next reads it, which is the property under test. */ for (const [i, w] of walked.entries()) { - results[i] = { path: w.path, method: w.method, ...(await observe(w.instance, i + 1)) }; + const key = mockLimiterKeys.get(w.instance.getKey); + const dimension = key === authenticatedUserRateLimitKeyGenerator ? 'user' + : key === presentedCredentialRateLimitKeyGenerator ? 'credential' : 'cf'; + results[i] = { path: w.path, method: w.method, dimension, ...(await observe(w.instance, i + 1, dimension)) }; } /* eslint-enable no-await-in-loop, no-restricted-syntax */ expect(process.env.NODE_ENV).toBe(ENTRY_NODE_ENV); diff --git a/backend/__tests__/unit/routes/admin.users.test.js b/backend/__tests__/unit/routes/admin.users.test.js index 32fd411c64..6c9986f45a 100644 --- a/backend/__tests__/unit/routes/admin.users.test.js +++ b/backend/__tests__/unit/routes/admin.users.test.js @@ -15,6 +15,7 @@ jest.mock('../../../middleware/adminAuth', () => (_req, _res, next) => next()); jest.mock('../../../models/User', () => ({ find: jest.fn(), findById: jest.fn(), + findOneAndUpdate: jest.fn(), countDocuments: jest.fn(), })); @@ -147,11 +148,15 @@ describe('admin users routes', () => { save: jest.fn().mockResolvedValue(true), }; User.findById.mockResolvedValue(target); + User.findOneAndUpdate.mockResolvedValue({ ...target, role: 'admin' }); await handler(req, res); - expect(target.role).toBe('admin'); - expect(target.save).toHaveBeenCalledTimes(1); + expect(User.findOneAndUpdate).toHaveBeenCalledWith( + { _id: '000000000000000000000002', accountDeletionRequestedAt: { $exists: false } }, + { $set: { role: 'admin' } }, { new: true }, + ); + expect(target.save).not.toHaveBeenCalled(); expect(res.json).toHaveBeenCalledWith( expect.objectContaining({ message: 'User role updated successfully', diff --git a/backend/__tests__/unit/routes/pods.test.js b/backend/__tests__/unit/routes/pods.test.js index e1d455c982..fac3a84fa8 100644 --- a/backend/__tests__/unit/routes/pods.test.js +++ b/backend/__tests__/unit/routes/pods.test.js @@ -58,6 +58,21 @@ describe('pods routes', () => { expect(limited.body.msg).toMatch(/20 pod joins per 60s/); }); + it.each([ + ['post', '/api/pods', 201, 'createPod'], + ['post', '/api/pods/123/leave', 200, 'leavePod'], + ['delete', '/api/pods/123/members/456', 200, 'removeMember'], + ])('bounds lifecycle writes on %s %s without consuming another token’s budget', async (method, url, status, handler) => { + const token = `Bearer lifecycle-${handler}`; + const callsBefore = controllers[handler].mock.calls.length; + for (let i = 0; i < 60; i += 1) { + await request(app)[method](url).set('Authorization', token).expect(status); + } + await request(app)[method](url).set('Authorization', token).expect(429); + expect(controllers[handler].mock.calls.length - callsBefore).toBe(60); + await request(app)[method](url).set('Authorization', `${token}-other`).expect(status); + }); + it('DELETE /api/pods/123/members/456 calls removeMember', async () => { await request(app).delete('/api/pods/123/members/456').expect(200); expect(controllers.removeMember).toHaveBeenCalled(); diff --git a/backend/__tests__/unit/routes/registry.runtime-tokens.test.js b/backend/__tests__/unit/routes/registry.runtime-tokens.test.js index d0eb351077..1c57572f09 100644 --- a/backend/__tests__/unit/routes/registry.runtime-tokens.test.js +++ b/backend/__tests__/unit/routes/registry.runtime-tokens.test.js @@ -1,3 +1,6 @@ +// These unit cases isolate runtime identity behavior. The deletion fence is +// exercised with real models in service/accountDeletion.test.js. +jest.mock('../../../services/accountDeletionState', () => ({ isAccountDeleting: jest.fn().mockResolvedValue(false) })); const request = require('supertest'); const express = require('express'); diff --git a/backend/__tests__/unit/routes/routeRateLimitGuard.baseline.json b/backend/__tests__/unit/routes/routeRateLimitGuard.baseline.json index e137c4cfe4..1264d5cb59 100644 --- a/backend/__tests__/unit/routes/routeRateLimitGuard.baseline.json +++ b/backend/__tests__/unit/routes/routeRateLimitGuard.baseline.json @@ -99,7 +99,6 @@ "routes/pg-status.ts GET / [unlimited]", "routes/pg-status.ts POST /sync-user [unlimited]", "routes/pods.ts DELETE /:id [unlimited]", - "routes/pods.ts DELETE /:id/members/:memberId [unlimited]", "routes/pods.ts DELETE /announcement/:id [unlimited]", "routes/pods.ts DELETE /external-link/:id [unlimited]", "routes/pods.ts GET /:id/children [unlimited]", @@ -112,8 +111,6 @@ "routes/pods.ts GET /:podId/files [unlimited]", "routes/pods.ts GET /:type/:id [unlimited]", "routes/pods.ts GET /external-link/:linkId/qrcode [unlimited]", - "routes/pods.ts POST / [unlimited]", - "routes/pods.ts POST /:id/leave [unlimited]", "routes/pods.ts POST /announcement [unlimited]", "routes/registry/admin.ts DELETE /admin/installations/:installationId [unlimited]", "routes/registry/admin.ts DELETE /admin/installations/:installationId/runtime-tokens/:tokenId [unlimited]", @@ -151,7 +148,6 @@ "routes/skills.ts POST /:skillId/rating [unlimited]", "routes/skills.ts POST /import [unlimited]", "routes/test-bot.ts POST /discord-summary [unlimited]", - "routes/uploads.ts POST / [unlimited]", "routes/users.ts DELETE /:id/follow [unlimited]", "routes/users.ts GET /:id [unlimited]", "routes/users.ts GET /:id/public-activity [unlimited]", diff --git a/backend/__tests__/unit/routes/uploads.post.test.js b/backend/__tests__/unit/routes/uploads.post.test.js index 84aaa5aadf..7248adc41c 100644 --- a/backend/__tests__/unit/routes/uploads.post.test.js +++ b/backend/__tests__/unit/routes/uploads.post.test.js @@ -25,8 +25,12 @@ jest.mock('../../../models/File', () => { }); const File = require('../../../models/File'); +jest.mock('../../../models/User', () => ({ + findById: jest.fn(() => ({ select: () => ({ lean: async () => ({ _id: 'user1' }) }) })), +})); + jest.mock('../../../middleware/auth', () => (req, res, next) => { - req.userId = 'user1'; + req.userId = /^Bearer quota-test-/.test(req.get('Authorization') || '') ? 'quota-user' : 'user1'; next(); }); @@ -68,6 +72,18 @@ describe('uploads POST / (ADR-002 Phase 1)', () => { expect(res.body.url).not.toMatch(/^https?:\/\//); }); + it('limits one user despite forged forwarding headers and rotating sessions', async () => { + for (let attempt = 0; attempt < 60; attempt += 1) { + await request(app).post('/api/uploads') + .set('Authorization', `Bearer quota-test-${attempt % 2}`) + .set('cf-connecting-ip', `198.51.100.${attempt}`) + .set('X-Forwarded-For', `203.0.113.${attempt}`).expect(400); + } + await request(app).post('/api/uploads').set('Authorization', 'Bearer quota-test-new') + .set('cf-connecting-ip', '192.0.2.1').expect(429); + await request(app).post('/api/uploads').expect(400); + }); + it('returns 400 when no file is provided', async () => { const res = await request(app).post('/api/uploads').expect(400); expect(res.body.msg).toBe('No file uploaded'); diff --git a/backend/__tests__/unit/services/objectStore/mongoDriver.test.ts b/backend/__tests__/unit/services/objectStore/mongoDriver.test.ts index 851d4ff09e..97cad717d4 100644 --- a/backend/__tests__/unit/services/objectStore/mongoDriver.test.ts +++ b/backend/__tests__/unit/services/objectStore/mongoDriver.test.ts @@ -66,6 +66,18 @@ describe('MongoObjectStore (ADR-002 Phase 1)', () => { expect(got).toBeNull(); }); + it('deletes only an exact ownership namespace and refuses an empty prefix', async () => { + await store.put('user-abc-one.png', Buffer.from('a'), 'image/png'); + await store.put('user-abc-two.png', Buffer.from('b'), 'image/png'); + await store.put('user-abcd-keep.png', Buffer.from('c'), 'image/png'); + await expect(store.deletePrefix('')).rejects.toThrow('prefix'); + expect(await store.get('user-abc-one.png')).not.toBeNull(); + await store.deletePrefix('user-abc-'); + expect(await store.get('user-abc-one.png')).toBeNull(); + expect(await store.get('user-abc-two.png')).toBeNull(); + expect(await store.get('user-abcd-keep.png')).not.toBeNull(); + }); + it('delete is a no-op for a missing key', async () => { await expect(store.delete('never-existed')).resolves.toBeUndefined(); }); diff --git a/backend/__tests__/unit/services/schedulerService.assignedTasks.test.js b/backend/__tests__/unit/services/schedulerService.assignedTasks.test.js index 2411711339..d90646d1a1 100644 --- a/backend/__tests__/unit/services/schedulerService.assignedTasks.test.js +++ b/backend/__tests__/unit/services/schedulerService.assignedTasks.test.js @@ -4,6 +4,10 @@ jest.mock('node-cron', () => ({ jest.mock('../../../services/assignedTaskWakeService', () => ({ dispatchAssignedTaskWakes: jest.fn().mockResolvedValue({ eligible: 0, woken: 0 }), })); +jest.mock('../../../services/accountDeletionService', () => ({ + sweepAccountDeletions: jest.fn().mockResolvedValue(undefined), +})); +const { sweepAccountDeletions } = require('../../../services/accountDeletionService'); const cron = require('node-cron'); const scheduler = require('../../../services/schedulerService'); const { dispatchAssignedTaskWakes } = require('../../../services/assignedTaskWakeService'); @@ -20,6 +24,7 @@ it('starts the assigned-row dispatcher on the one-minute clock and stops it with await job.tick(); } expect(dispatchAssignedTaskWakes).toHaveBeenCalledTimes(1); + expect(sweepAccountDeletions).toHaveBeenCalledTimes(1); scheduler.stop(); expect(minuteJobs.every((job) => job.stop.mock.calls.length === 1)).toBe(true); } finally { diff --git a/backend/__tests__/unit/services/sessionAccountService.test.js b/backend/__tests__/unit/services/sessionAccountService.test.js index 3f825bc2ed..ea05fd3f98 100644 --- a/backend/__tests__/unit/services/sessionAccountService.test.js +++ b/backend/__tests__/unit/services/sessionAccountService.test.js @@ -60,6 +60,11 @@ describe('sessionAccountService', () => { expect(sessionRefusal(await loadSessionAccount(String(user._id)))).toBe('banned'); }); + it('projects and refuses a pending deletion marker', async () => { + const user = await row({ accountDeletionRequestedAt: new Date() }); + expect(sessionRefusal(await loadSessionAccount(String(user._id)))).toBe('deleting'); + }); + it('refuses a row that no longer exists', async () => { expect(sessionRefusal(await loadSessionAccount('6a8f6dc7a1dccf2e02f31015'))).toBe('missing'); }); diff --git a/backend/__tests__/utils/testUtils.js b/backend/__tests__/utils/testUtils.js index f3a6e5abec..4a4addc067 100644 --- a/backend/__tests__/utils/testUtils.js +++ b/backend/__tests__/utils/testUtils.js @@ -155,7 +155,8 @@ const setupPgDb = async () => { implementation: () => require('crypto').randomUUID(), }); - pgPool = pgDb.adapters.createPg().pool(); + const { Pool } = pgDb.adapters.createPg(); + pgPool = new Pool(); await pgPool.query(` CREATE TABLE IF NOT EXISTS pods ( diff --git a/backend/controllers/authController.ts b/backend/controllers/authController.ts index d16f81038d..6acfabc3fe 100644 --- a/backend/controllers/authController.ts +++ b/backend/controllers/authController.ts @@ -932,6 +932,10 @@ exports.getCurrentUser = async (req: any, res: any) => { if (!user) { return res.status(404).json({ error: 'User not found' }); } + if (user.accountDeletionRequestedAt) { + return res.json({ _id: user._id, username: user.username, email: user.email, + role: user.role, accountDeletionPending: true }); + } res.json(user); } catch (err: any) { res.status(500).json({ error: err.message }); diff --git a/backend/controllers/oauthController.ts b/backend/controllers/oauthController.ts index fd400cdeef..bc4f13f5fb 100644 --- a/backend/controllers/oauthController.ts +++ b/backend/controllers/oauthController.ts @@ -198,7 +198,7 @@ const generateUsername = async (hint: string, email: string) => { }; const issueSession = (user: any) => { - const token = issueSessionToken(user); + const token = issueSessionToken(user, undefined, undefined, Math.floor(Date.now() / 1000)); return { token, verified: user.verified, diff --git a/backend/controllers/podController.ts b/backend/controllers/podController.ts index 9b6a27b82c..54e188e2fe 100644 --- a/backend/controllers/podController.ts +++ b/backend/controllers/podController.ts @@ -1,3 +1,4 @@ +import { podMemberPopulate } from '../services/podMemberPopulate'; const mongoose = require('mongoose'); const Pod = require('../models/Pod'); const Message = require('../models/Message'); @@ -248,7 +249,7 @@ exports.getAllPods = async (req: any, res: any) => { let pods = await Pod.find(listedQuery) .populate('createdBy', 'username profilePicture') - .populate('members', 'username profilePicture isBot') + .populate(podMemberPopulate('username profilePicture isBot')) .populate('parentPod', 'name _id') .sort({ updatedAt: -1 }); @@ -313,7 +314,7 @@ exports.getPodsByType = async (req: any, res: any) => { const pods = await Pod.find({ type }) .populate('createdBy', 'username profilePicture') - .populate('members', 'username profilePicture isBot') + .populate(podMemberPopulate('username profilePicture isBot')) .sort({ updatedAt: -1 }); // Personal pod types (agent-admin, agent-room, agent-dm) are always @@ -344,7 +345,7 @@ exports.getPodById = async (req: any, res: any) => { // Get the pod with populated data const pod = await Pod.findById(id) .populate('createdBy', 'username profilePicture') - .populate('members', 'username profilePicture isBot') + .populate(podMemberPopulate('username profilePicture isBot')) .populate('parentPod', 'name _id'); if (!pod) { @@ -449,7 +450,7 @@ exports.createPod = async (req: any, res: any) => { // Populate the user data await pod.populate('createdBy', 'username profilePicture'); - await pod.populate('members', 'username profilePicture isBot'); + await pod.populate(podMemberPopulate('username profilePicture isBot')); // Also create in PostgreSQL if available try { @@ -573,7 +574,7 @@ exports.joinPod = async (req: any, res: any) => { const updatedPod = await Pod.findById(id) .populate('createdBy', 'username profilePicture') - .populate('members', 'username profilePicture isBot'); + .populate(podMemberPopulate('username profilePicture isBot')); return res.json(updatedPod); } catch (err: any) { @@ -626,7 +627,7 @@ exports.leavePod = async (req: any, res: any) => { // Populate the user data await pod.populate('createdBy', 'username profilePicture'); - await pod.populate('members', 'username profilePicture isBot'); + await pod.populate(podMemberPopulate('username profilePicture isBot')); res.json(pod); } catch (err: any) { @@ -694,7 +695,7 @@ exports.removeMember = async (req: any, res: any) => { } await pod.populate('createdBy', 'username profilePicture'); - await pod.populate('members', 'username profilePicture isBot'); + await pod.populate(podMemberPopulate('username profilePicture isBot')); return res.json(pod); } catch (err: any) { diff --git a/backend/controllers/userController.ts b/backend/controllers/userController.ts index f67f224806..d91744b775 100644 --- a/backend/controllers/userController.ts +++ b/backend/controllers/userController.ts @@ -43,7 +43,7 @@ const PRIVATE_USER_FIELDS = [ // Reservation metadata is used only by username availability checks. It is // not part of a profile response: historical message text remains the only // public record of the previous handle. -const INTERNAL_USER_FIELDS = ['previousUsernames']; +const INTERNAL_USER_FIELDS = ['previousUsernames', 'accountDeletionRequestedAt']; const isValidAccountLabel = (value: unknown) => typeof value === 'string' && value.trim().length > 0 diff --git a/backend/middleware/agentRuntimeAuth.ts b/backend/middleware/agentRuntimeAuth.ts index 6ff26d49ff..b0d58e5714 100644 --- a/backend/middleware/agentRuntimeAuth.ts +++ b/backend/middleware/agentRuntimeAuth.ts @@ -1,3 +1,4 @@ +import { isAccountDeleting } from '../services/accountDeletionState'; import { resolveTokenAgentIdentity } from '../utils/agentTokenIdentity'; import { Request, Response, NextFunction } from 'express'; import { AgentInstallation } from '../models/AgentRegistry'; @@ -76,6 +77,9 @@ export default async function agentRuntimeAuth(req: Request, res: Response, next if (credential.status !== 'active') { return res.status(401).json({ message: 'Token revoked' }); } + if (await isAccountDeleting(credential.ownerUserId)) { + return res.status(401).json({ message: 'Credential owner is being deleted' }); + } if (credential.expiresAt && credential.expiresAt < new Date()) { return res.status(401).json({ message: 'Session token expired' }); } diff --git a/backend/middleware/auth.ts b/backend/middleware/auth.ts index 4d938c9ea2..12e9657e74 100644 --- a/backend/middleware/auth.ts +++ b/backend/middleware/auth.ts @@ -51,7 +51,7 @@ export default async function auth(req: Request, res: Response, next: NextFuncti // bearers share its public cm_ prefix but are stored only as a hash so a // database read cannot impersonate a CLI session. let user: any = await User.findOne({ apiToken: token }).select( - '_id username email role apiTokenScopes apiTokenCreatedAt banned', + '_id username email role apiTokenScopes apiTokenCreatedAt banned accountDeletionRequestedAt', ); let isDeviceToken = false; @@ -66,7 +66,7 @@ export default async function auth(req: Request, res: Response, next: NextFuncti revokedAt: { $in: [null] }, }, }, - }).select('_id username email role banned'); + }).select('_id username email role banned accountDeletionRequestedAt'); isDeviceToken = Boolean(user); } @@ -75,6 +75,10 @@ export default async function auth(req: Request, res: Response, next: NextFuncti return res.status(403).json({ msg: 'This account has been suspended.' }); } + if (user.accountDeletionRequestedAt) { + return res.status(403).json({ code: 'ACCOUNT_DELETING', msg: 'Account deletion is pending.' }); + } + req.userId = user._id.toString(); req.user = { id: user._id.toString(), @@ -129,6 +133,17 @@ export default async function auth(req: Request, res: Response, next: NextFuncti } if (refusal === 'revoked') return res.status(401).json({ msg: 'Session has been revoked' }); + if (refusal === 'deleting') { + // These canonical routes are the complete recovery surface. DELETE still + // requires password/fresh OAuth in its service. The read-only bootstrap + // lets AuthContext reopen Settings after provider sign-in or reload. + const retry = req.method === 'DELETE' && req.baseUrl === '/api/users' && req.path === '/me'; + const bootstrap = req.method === 'GET' && req.baseUrl === '/api/auth' && req.path === '/user'; + if (!retry && !bootstrap) { + return res.status(403).json({ code: 'ACCOUNT_DELETING', msg: 'Account deletion is pending.' }); + } + } + req.userId = id; req.user = { id, @@ -140,7 +155,7 @@ export default async function auth(req: Request, res: Response, next: NextFuncti req.sessionTokenVersion = decoded.tokenVersion === undefined ? 0 : decoded.tokenVersion as number; req.sessionStartedAt = typeof decoded.sessionStartedAt === 'number' ? decoded.sessionStartedAt : decoded.iat as number | undefined; - touchLastActive(id); + if (!account!.accountDeletionRequestedAt) touchLastActive(id); next(); } catch (err: unknown) { console.error('Token validation error:', (err as Error).message); diff --git a/backend/middleware/ipRateLimit.ts b/backend/middleware/ipRateLimit.ts index b45af015b6..05b268e961 100644 --- a/backend/middleware/ipRateLimit.ts +++ b/backend/middleware/ipRateLimit.ts @@ -1,4 +1,5 @@ import type { Request } from 'express'; +import { createHash } from 'crypto'; import { ipKeyGenerator } from 'express-rate-limit'; const firstHeaderValue = (value: string | string[] | undefined): string | undefined => { @@ -54,6 +55,21 @@ export const cloudflareIpRateLimitKeyGenerator = (req: Request): string => { return req.ip ? ipKeyGenerator(req.ip) : 'anon'; }; +// Pre-auth traffic bound per presented secret, not per proxy or forwarding +// header. Anonymous requests share only the anonymous bucket, never a signed-in +// user's. Rotating invalid secrets can evade this coarse bound; the post-auth +// user quota is the durable account limit. Never retain plaintext credentials. +export const presentedCredentialRateLimitKeyGenerator = (req: Request): string => { + const token = req.header('Authorization')?.replace('Bearer ', '') || req.header('x-auth-token'); + return token ? `credential:${createHash('sha256').update(token).digest('hex')}` : 'no-credential'; +}; + +export const authenticatedUserRateLimitKeyGenerator = (req: Request & { userId?: unknown }): string => ( + req.userId ? `user:${String(req.userId)}` : 'unresolved-user' +); + // CJS compat for the require()-style imports used elsewhere in backend/. // eslint-disable-next-line @typescript-eslint/no-require-imports -module.exports = { cloudflareIpRateLimitKeyGenerator }; +module.exports = { + cloudflareIpRateLimitKeyGenerator, presentedCredentialRateLimitKeyGenerator, authenticatedUserRateLimitKeyGenerator, +}; diff --git a/backend/models/AccountDeletion.ts b/backend/models/AccountDeletion.ts new file mode 100644 index 0000000000..d29061508d --- /dev/null +++ b/backend/models/AccountDeletion.ts @@ -0,0 +1,19 @@ +import mongoose, { Document, Schema, Types } from 'mongoose'; + +// Minimal durable deletion intent. Retain the opaque identity after User is +// gone so an upload already in flight cannot strand bytes on a cleanup error. +// No email, profile, password or token material belongs on this record. +export interface IAccountDeletion extends Document { + _id: Types.ObjectId; + createdAt: Date; + lastSweptAt?: Date; +} +const schema = new Schema({ + _id: { type: Schema.Types.ObjectId, required: true }, + createdAt: { type: Date, default: Date.now }, + lastSweptAt: { type: Date }, +}); +schema.index({ lastSweptAt: 1, _id: 1 }); +export default mongoose.model('AccountDeletion', schema); +// eslint-disable-next-line @typescript-eslint/no-require-imports +module.exports = exports.default; Object.assign(module.exports, exports); diff --git a/backend/models/File.ts b/backend/models/File.ts index 6ed2378a04..eefcafe268 100644 --- a/backend/models/File.ts +++ b/backend/models/File.ts @@ -39,6 +39,7 @@ const fileSchema = new Schema({ }); fileSchema.index({ fileName: 1 }); +fileSchema.index({ uploadedBy: 1 }); fileSchema.index({ podId: 1, createdAt: -1 }); fileSchema.statics.findByFileName = function (fileName: string) { diff --git a/backend/models/SchedulerHeartbeatLease.ts b/backend/models/SchedulerHeartbeatLease.ts index f87f6ac3c4..2783e16ae0 100644 --- a/backend/models/SchedulerHeartbeatLease.ts +++ b/backend/models/SchedulerHeartbeatLease.ts @@ -1,7 +1,8 @@ import mongoose, { Document, Model, Schema } from 'mongoose'; /** - * A short-lived, cluster-wide lease for one agent-heartbeat dispatch. The + * A short-lived, cluster-wide lease for scheduled work (heartbeats and + * account deletion). The legacy collection name remains stable. The * fixed _id is the natural key: MongoDB's primary-key uniqueness makes the * acquire compare-and-set atomic without depending on index build timing. */ diff --git a/backend/models/User.ts b/backend/models/User.ts index 1f3d2b8c64..3be3e3ba82 100644 --- a/backend/models/User.ts +++ b/backend/models/User.ts @@ -87,6 +87,7 @@ export interface IUser extends Document { banned: boolean; // Incrementing this invalidates every browser session without rotating JWT_SECRET. tokenVersion: number; + accountDeletionRequestedAt?: Date; bannedAt?: Date; banReason?: string; profilePicture: string; @@ -279,6 +280,7 @@ const userSchema = new Schema({ verified: { type: Boolean, default: false }, banned: { type: Boolean, default: false }, tokenVersion: { type: Number, default: 0 }, + accountDeletionRequestedAt: { type: Date }, bannedAt: { type: Date }, banReason: { type: String }, profilePicture: { type: String, default: 'default' }, @@ -519,6 +521,11 @@ userSchema.methods.getOrCreateDigestUnsubscribeToken = function (): string { return this.digestUnsubscribeToken; }; +// Recovery scans only authorized deletion markers, ordered for bounded retries. +userSchema.index({ accountDeletionRequestedAt: 1, _id: 1 }, { + partialFilterExpression: { accountDeletionRequestedAt: { $exists: true } }, +}); + export default mongoose.model('User', userSchema); // CJS compat: let require() return the default export directly // eslint-disable-next-line @typescript-eslint/no-require-imports diff --git a/backend/routes/admin/users.ts b/backend/routes/admin/users.ts index 83cfe70b6e..3e2ed22294 100644 --- a/backend/routes/admin/users.ts +++ b/backend/routes/admin/users.ts @@ -155,9 +155,7 @@ router.patch('/:userId/role', adminWriteLimiter, auth, adminAuth, async (req: an if (!isValidObjectId(userId)) return mutationResponse(400, { error: 'Invalid user ID' }); const target = await User.findById(userId); - if (!target) { - return mutationResponse(404, { error: 'User not found' }); - } + if (!target) return mutationResponse(404, { error: 'User not found' }); if (target.role === 'admin' && nextRole === 'user') { const adminCount = await User.countDocuments({ role: 'admin' }); @@ -166,14 +164,23 @@ router.patch('/:userId/role', adminWriteLimiter, auth, adminAuth, async (req: an } } - target.role = nextRole; + // The global lock serializes administrator writers, but self-deletion + // claims do not take it. Keep their target-document marker predicate too: + // promotion wins and deletion refuses, or deletion wins and this refuses. beginWrite(); - await target.save(); - + const updated = await User.findOneAndUpdate( + { _id: userId, accountDeletionRequestedAt: { $exists: false } }, + { $set: { role: nextRole } }, + { new: true }, + ); + if (!updated) { + return mutationResponse(409, { error: 'This account is being deleted; its role cannot change.' }); + } return mutationResponse(200, { message: 'User role updated successfully', - user: sanitizeUser(target), + user: sanitizeUser(updated), }); + }); return res.status(result.status).json(result.body); } catch (error) { diff --git a/backend/routes/agentsRuntime.ts b/backend/routes/agentsRuntime.ts index 13f6a56948..778058aa34 100644 --- a/backend/routes/agentsRuntime.ts +++ b/backend/routes/agentsRuntime.ts @@ -1,3 +1,4 @@ +import { podMemberPopulate } from '../services/podMemberPopulate'; import dualAuth from '../middleware/dualAuth'; import { validateAgentCardPayload } from '../services/uiCardValidation'; export {}; @@ -3287,7 +3288,7 @@ router.post('/pods', phase4RateLimit, agentRuntimeAuth, async (req: any, res: an // Global dedup by name: if a pod with this name already exists anywhere, join it and return it const existingPod = await Pod.findOne({ name }) .populate('createdBy', 'username profilePicture') - .populate('members', 'username profilePicture'); + .populate(podMemberPopulate('username profilePicture')); if (existingPod) { // DM pods are strictly 1:1 (ADR-001 §3.10). VALID_POD_TYPES above gates // the type the caller ASKED for, but this dedup branch matches on name @@ -3379,7 +3380,7 @@ router.post('/pods', phase4RateLimit, agentRuntimeAuth, async (req: any, res: an if (!isMember) { existingPod.members.push(agentUser._id); await existingPod.save(); - await existingPod.populate('members', 'username profilePicture'); + await existingPod.populate(podMemberPopulate('username profilePicture')); } // Ensure an AgentInstallation exists so the agent appears in the Agents section if (sourceInstall) { @@ -3431,7 +3432,7 @@ router.post('/pods', phase4RateLimit, agentRuntimeAuth, async (req: any, res: an const pod = await newPod.save(); await pod.populate('createdBy', 'username profilePicture'); - await pod.populate('members', 'username profilePicture'); + await pod.populate(podMemberPopulate('username profilePicture')); if (process.env.PG_HOST && PGPod) { try { diff --git a/backend/routes/podInvites.ts b/backend/routes/podInvites.ts index 38802649fc..9139b67921 100644 --- a/backend/routes/podInvites.ts +++ b/backend/routes/podInvites.ts @@ -1,3 +1,4 @@ +import { podMemberPopulate } from '../services/podMemberPopulate'; // Pod invite tokens — shareable URLs that let a user join a pod without // going through the invite-only joinPolicy gate. Tokens are random // (16-byte hex), DB-backed (PodInvite), and bound to a single pod by their @@ -304,7 +305,7 @@ router.post('/invites/:token/redeem', inviteWriteRateLimit, auth, async (req: an await invite.save(); const updated = await Pod.findById(pod._id) .populate('createdBy', 'username profilePicture') - .populate('members', 'username profilePicture isBot'); + .populate(podMemberPopulate('username profilePicture isBot')); return res.json({ ok: true, alreadyMember, pod: updated }); } catch (err: any) { console.error('Redeem invite failed:', err.message); diff --git a/backend/routes/pods.ts b/backend/routes/pods.ts index ff44cd0b74..8a65fa6c52 100644 --- a/backend/routes/pods.ts +++ b/backend/routes/pods.ts @@ -1,3 +1,4 @@ +import { podMemberPopulate } from '../services/podMemberPopulate'; import path from 'path'; import fs from 'fs'; import { createHash } from 'crypto'; @@ -119,8 +120,18 @@ const podListingRateLimit = rateLimit({ legacyHeaders: false, keyGenerator: podJoinRateLimitKey, }); +// Bound lifecycle writes before authentication. Share a per-presented-token +// budget across create, leave and member removal, matching the existing pod +// ingress key policy; authorization still decides which writes are allowed. +const podLifecycleRateLimit = rateLimit({ + windowMs: 60_000, + limit: 60, + standardHeaders: 'draft-7', + legacyHeaders: false, + keyGenerator: podJoinRateLimitKey, +}); router.get('/', podListingRateLimit, auth, getAllPods); -router.post('/', auth, createPod); +router.post('/', podLifecycleRateLimit, auth, createPod); router.post('/announcement', auth, async (req: AuthReq, res: Res) => { try { @@ -405,8 +416,8 @@ router.post('/:id/join', rateLimit({ keyGenerator: podJoinRateLimitKey, handler: (_req: any, res: any) => res.status(429).json({ msg: 'rate limit exceeded: 20 pod joins per 60s' }), }), auth, joinPod); -router.post('/:id/leave', auth, leavePod); -router.delete('/:id/members/:memberId', auth, removeMember); +router.post('/:id/leave', podLifecycleRateLimit, auth, leavePod); +router.delete('/:id/members/:memberId', podLifecycleRateLimit, auth, removeMember); // Pod admin actions hit Mongo on every call but don't need much volume. // Capping per-IP keeps a stuck client from re-pinging contacts at full @@ -625,7 +636,7 @@ router.get('/:id/children', auth, async (req: AuthReq, res: Res) => { if (!canViewParent) return res.status(403).json({ msg: 'Not authorized to view child pods' }); const children = await Pod.find({ parentPod: parentId }) .populate('createdBy', 'username profilePicture') - .populate('members', 'username profilePicture') + .populate(podMemberPopulate('username profilePicture')) .sort({ name: 1 }); const visibleChildren = []; for (const child of children) { diff --git a/backend/routes/registry/tokens.ts b/backend/routes/registry/tokens.ts index 39562953a0..83cd49d987 100644 --- a/backend/routes/registry/tokens.ts +++ b/backend/routes/registry/tokens.ts @@ -1,3 +1,4 @@ +import { isAccountDeleting } from '../../services/accountDeletionState'; // Token issuance + scope helpers — extracted from registry.js (GH#112) const AgentIdentityService = require('../../services/agentIdentityService'); const { hash, randomSecret } = require('../../utils/secret'); @@ -266,6 +267,26 @@ const runsInProcess = async (agentUser: any, installation: any): Promise { + const ownerId = issuer?.ownerUserId || installation?.installedBy; + const refuseDeletingOwner = async (tokenHash?: string, mintedHere = false) => { + if (!ownerId || !await isAccountDeleting(ownerId)) return; + if (tokenHash) { + // A shared identity can already hold another owner's token. A refusal + // must only revoke the credential this deleting owner actually owns. + // A new hash belongs to this request even on legacy reprovision paths + // that do not create an issuer-backed ledger row. + // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports + const AgentCredential = require('../../models/AgentCredential'); + if (mintedHere || await AgentCredential.exists({ tokenHash, ownerUserId: ownerId })) { + await revokeRuntimeTokenEverywhere(tokenHash); + } + } + const error = Object.assign(new Error('This account is being deleted; runtime credentials cannot be issued.'), { + code: 'account_deleting', status: 409, + }); + throw error; + }; + await refuseDeletingOwner(); if (await runsInProcess(agentUser, installation)) throw new InProcessAgentTokenRefused(); // Check if agent already has a runtime token (reuse existing) const existingToken = agentUser.agentRuntimeTokens?.[0] || installation?.runtimeTokens?.[0]; @@ -291,6 +312,7 @@ const issueRuntimeTokenForAgent = async (agentUser: any, label: any, installatio { upsert: true }, ).catch((err: Error) => console.warn('Credential backfill failed:', err.message)); } + await refuseDeletingOwner(existingToken.tokenHash); return { existing: true, label: existingToken.label, @@ -350,6 +372,7 @@ const issueRuntimeTokenForAgent = async (agentUser: any, label: any, installatio throw new InProcessAgentTokenRefused(); } + await refuseDeletingOwner(tokenRecord.tokenHash, true); return { token: rawToken, label: label || 'Runtime token', diff --git a/backend/routes/uploads.ts b/backend/routes/uploads.ts index be1cf671e4..8418b16ca8 100644 --- a/backend/routes/uploads.ts +++ b/backend/routes/uploads.ts @@ -29,7 +29,9 @@ import { verifyAttachmentToken, } from '../services/attachmentAccess'; import { logAttachmentTokenMint } from '../services/auditService'; -import { cloudflareIpRateLimitKeyGenerator } from '../middleware/ipRateLimit'; +import { + cloudflareIpRateLimitKeyGenerator, presentedCredentialRateLimitKeyGenerator, authenticatedUserRateLimitKeyGenerator, +} from '../middleware/ipRateLimit'; import { normalizeAvatarUrl } from '../services/avatarService'; // eslint-disable-next-line global-require const express = require('express'); @@ -38,6 +40,8 @@ const multer = require('multer'); // eslint-disable-next-line global-require const File = require('../models/File'); // eslint-disable-next-line global-require +const User = require('../models/User'); +// eslint-disable-next-line global-require const auth = require('../middleware/auth'); const { refuseDollarKeys } = require('../middleware/refuseDollarKeys'); // eslint-disable-next-line global-require @@ -214,11 +218,16 @@ const handleUpload = async ( return; } + const uploader = await User.findById(uploaderId).select('accountDeletionRequestedAt').lean(); + if (!uploader || uploader.accountDeletionRequestedAt) { + res.status(403).json({ msg: 'Account deletion is in progress. Uploads are disabled.' }); + return; + } const store = getObjectStore(); const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`; const ext = path.extname(req.file.originalname); - const fileName = `${uniqueSuffix}${ext}`; + const fileName = `user-${uploaderId}-${uniqueSuffix}${ext}`; const kind = kindFromName(req.file.originalname); const podId = req.body?.podId?.toString().trim() || null; @@ -237,6 +246,17 @@ const handleUpload = async ( }); await newFile.save(); + // A request authenticated before deletion may finish its upload after the + // deletion scan. Recheck AFTER metadata exists: either the scan owns it, + // or this request removes both the bytes and its metadata. + const remainingUploader = await User.findById(uploaderId).select('accountDeletionRequestedAt').lean(); + if (!remainingUploader || remainingUploader.accountDeletionRequestedAt) { + await store.delete(fileName); + await File.deleteOne({ _id: newFile._id }); + res.status(403).json({ msg: 'Account deletion is in progress. Upload discarded.' }); + return; + } + // Stored upload references must survive hostname changes. Callers can // persist this response directly without baking the current API origin // into User.profilePicture or package icon metadata. @@ -258,7 +278,23 @@ const handleUpload = async ( } }; -router.post('/', auth, uploadSingle('image'), async (req: AuthReq, res: Res) => { +const uploadCredentialRateLimit = rateLimit({ + windowMs: 15 * 60_000, + max: 6000, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: presentedCredentialRateLimitKeyGenerator, +}); + +const uploadRateLimit = rateLimit({ + windowMs: 15 * 60_000, + max: 60, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: authenticatedUserRateLimitKeyGenerator, +}); + +router.post('/', uploadCredentialRateLimit, auth, uploadRateLimit, uploadSingle('image'), async (req: AuthReq, res: Res) => { if (!req.userId) { res.status(401).json({ msg: 'auth required' }); return; diff --git a/backend/routes/users.ts b/backend/routes/users.ts index 01b2467f8b..91d9c00dca 100644 --- a/backend/routes/users.ts +++ b/backend/routes/users.ts @@ -2,7 +2,10 @@ // DB-backed polling route below (same established pattern as messages.ts). import rateLimit from 'express-rate-limit'; import { createHash } from 'crypto'; -import { cloudflareIpRateLimitKeyGenerator } from '../middleware/ipRateLimit'; +import { AccountDeletionError, deleteOwnAccount } from '../services/accountDeletionService'; +import { + cloudflareIpRateLimitKeyGenerator, presentedCredentialRateLimitKeyGenerator, authenticatedUserRateLimitKeyGenerator, +} from '../middleware/ipRateLimit'; // eslint-disable-next-line global-require const express = require('express'); // eslint-disable-next-line global-require @@ -88,6 +91,44 @@ const profileWriteUserLimit = rateLimit({ }), }); +const accountDeletionCredentialRateLimit = rateLimit({ + windowMs: 15 * 60_000, + max: 120, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: presentedCredentialRateLimitKeyGenerator, +}); + +const accountDeletionRateLimit = rateLimit({ + windowMs: 15 * 60_000, + max: 10, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: authenticatedUserRateLimitKeyGenerator, +}); + +router.delete('/me', accountDeletionCredentialRateLimit, auth, accountDeletionRateLimit, async (req: any, res: any) => { + if (req.authType !== 'jwt') { + return res.status(403).json({ + code: 'BROWSER_SESSION_REQUIRED', error: 'Sign in through the browser to delete your account.', + }); + } + if (req.body?.confirmation !== 'DELETE') { + return res.status(400).json({ code: 'CONFIRMATION_REQUIRED', error: 'Confirm account deletion by typing DELETE.' }); + } + try { + const bearer = req.get('Authorization')?.replace(/^Bearer\s+/i, '') || req.get('x-auth-token') || ''; + await deleteOwnAccount(String(req.userId), bearer, req.body?.password); + return res.status(204).send(); + } catch (error) { + if (error instanceof AccountDeletionError) { + return res.status(error.status).json({ code: error.code, error: error.message }); + } + console.error('Account deletion failed:', error instanceof Error ? error.name : 'Unknown error'); + return res.status(503).json({ error: 'Account deletion could not finish. Please retry to complete cleanup.' }); + } +}); + router.get('/me/agent-connection', agentConnectionReadLimit, auth, async (req: any, res: any) => { try { const userId = req.userId || req.user?.id || req.user?._id; diff --git a/backend/services/accountDeletionService.ts b/backend/services/accountDeletionService.ts new file mode 100644 index 0000000000..502cfb3dc3 --- /dev/null +++ b/backend/services/accountDeletionService.ts @@ -0,0 +1,249 @@ +import jwt from 'jsonwebtoken'; +import { randomUUID } from 'crypto'; +import SchedulerHeartbeatLease from '../models/SchedulerHeartbeatLease'; +import User, { IUser } from '../models/User'; +import AccountDeletion from '../models/AccountDeletion'; +import File from '../models/File'; +import Pod from '../models/Pod'; +import DeviceAuthorization from '../models/DeviceAuthorization'; +import OAuthLoginState from '../models/OAuthLoginState'; +import AgentCredential from '../models/AgentCredential'; +import Machine from '../models/Machine'; +import { AgentInstallation } from '../models/AgentRegistry'; +import { getObjectStore } from './objectStore'; +import { disconnectUserSessionSockets } from './sessionSocketService'; +import { stripe } from './billingService'; +import { DM_POD_TYPES_GUARD } from './agentIdentityService'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { pool } = require('../config/db-pg'); + +export class AccountDeletionError extends Error { + constructor(public status: number, public code: string, message: string) { + super(message); + } +} + +const revokeOwnedAccess = async (userId: string): Promise => { + disconnectUserSessionSockets(userId); + await User.updateOne({ _id: userId }, { + $set: { deviceTokens: [] }, + $unset: { apiToken: 1, apiTokenCreatedAt: 1, apiTokenScopes: 1 }, + }); + + const credentials = await AgentCredential.find({ ownerUserId: userId }).select('_id').lean(); + await AgentCredential.revokeCascade(credentials.map((credential) => credential._id)); + await Machine.deleteMany({ ownerUserId: userId }); + // DM membership is historical: removing one side would break strict 1:1. + // The deleted account cannot authenticate; its owned runtimes pause below. + await Pod.updateMany({ members: userId, type: { $nin: [...DM_POD_TYPES_GUARD] } }, + { $pull: { members: userId } }); + await AgentInstallation.updateMany( + { installedBy: userId, status: 'active' }, + { $set: { status: 'paused' } }, + ); + await DeviceAuthorization.deleteMany({ userId }); + await OAuthLoginState.deleteMany({ userId }); + +}; + +const anonymizeChatAuthor = async (userId: string): Promise => { + // Shared messages remain. Their PG author join keeps an anonymous tombstone; + // it must not preserve the deleted person's display name or avatar. + if (pool) { + const dmTypes = [...DM_POD_TYPES_GUARD]; + const dmTypeParams = dmTypes.map((_type, index) => `$${index + 2}`).join(', '); + await pool.query( + `DELETE FROM pod_members WHERE user_id = $1 + AND pod_id NOT IN (SELECT id FROM pods WHERE type IN (${dmTypeParams}))`, + [userId, ...dmTypes], + ); + await pool.query( + 'UPDATE users SET username = $2, profile_picture = NULL, updated_at = $3 WHERE _id = $1', + [userId, 'Deleted user', new Date()], + ); + } +}; + +const deleteOwnedUploads = async (userId: string): Promise => { + // New keys carry an ownership namespace. This also finds bytes whose File + // save failed after put, or whose metadata was removed during a late put. + await getObjectStore().deletePrefix(`user-${userId}-`); + const files = await File.find({ uploadedBy: userId }).select('_id fileName').lean(); + for (const file of files) { + // Legacy keys have no namespace; retain metadata until deletion succeeds. + // eslint-disable-next-line no-await-in-loop + await getObjectStore().delete(file.fileName); + // eslint-disable-next-line no-await-in-loop + await File.deleteOne({ _id: file._id, uploadedBy: userId }); + } +}; + +// Only called after request re-authentication or for an already authorized, +// stale marker. Recovery retains a failed preflight for a later bounded retry. +const establishCleanupReceipt = async (user: IUser, releaseOnBillingFailure = true): Promise => { + const userId = String(user._id); + // A receipt means billing preflight already succeeded. Otherwise claim the + // non-admin row before Stripe. A stale claim recovers a crashed preflight; + // the provider request is bounded below this five-minute lease. + if (!await AccountDeletion.exists({ _id: userId })) { + const claimedAt = new Date(); + const claimed = await User.updateOne({ + _id: userId, isBot: { $ne: true }, role: { $ne: 'admin' }, + $or: [{ accountDeletionRequestedAt: { $exists: false } }, + { accountDeletionRequestedAt: { $lt: new Date(claimedAt.getTime() - 300_000) } }], + }, { $set: { accountDeletionRequestedAt: claimedAt } }); + if (claimed.matchedCount !== 1) { + throw new AccountDeletionError(409, 'ACCOUNT_CHANGED', + 'Your account changed or deletion is already in progress. Try again shortly.'); + } + try { + if (user.billing?.subscriptionId && user.billing.subscriptionStatus !== 'canceled') { + const client = stripe(); + if (!client) throw new Error('Billing is unavailable; account deletion cannot cancel the subscription'); + await client.subscriptions.cancel(user.billing.subscriptionId, {}, { + idempotencyKey: `account-deletion-${userId}-${user.billing.subscriptionId}`, + timeout: 30_000, + maxNetworkRetries: 0, + }); + } + } catch (error) { + // Release only this attempt's claim. Credentials and uploads are intact. + if (releaseOnBillingFailure) { + await User.updateOne({ _id: userId, accountDeletionRequestedAt: claimedAt }, + { $unset: { accountDeletionRequestedAt: 1 } }); + } + throw error; + } + await AccountDeletion.updateOne({ _id: userId }, { $setOnInsert: { createdAt: new Date() } }, { upsert: true }); + } +}; + +export const ACCOUNT_DELETION_SWEEP_BATCH = 100; +const SWEEP_LEASE_ID = 'account-deletion-sweep'; +const SWEEP_LEASE_MS = 120_000; + +/** Bounded, fair retries retain intents until all late producers are fenced. */ +export const sweepAccountDeletions = async (): Promise => { + const ownerId = randomUUID(); + const now = new Date(); + try { + await SchedulerHeartbeatLease.findOneAndUpdate( + { _id: SWEEP_LEASE_ID, expiresAt: { $lte: now } }, + { $set: { ownerId, expiresAt: new Date(now.getTime() + SWEEP_LEASE_MS) } }, + { upsert: true, new: true }, + ); + } catch (error) { + if ((error as { code?: number }).code === 11000) return; + throw error; + } + const renewLease = async (): Promise => { + const renewed = await SchedulerHeartbeatLease.updateOne( + { _id: SWEEP_LEASE_ID, ownerId }, + { $set: { expiresAt: new Date(Date.now() + SWEEP_LEASE_MS) } }, + ); + if (renewed.matchedCount !== 1) throw new Error('Account deletion sweep lease lost'); + }; + const errors: unknown[] = []; + const failedIds: string[] = []; + try { + // Recover the gap between the authorized marker and receipt creation. + // Fresh billing preflights are excluded. Existing receipts need no recovery. + const stranded = await User.aggregate([ + { $match: { accountDeletionRequestedAt: { $lt: new Date(Date.now() - 300_000) }, + role: { $ne: 'admin' }, isBot: { $ne: true } } }, + { $sort: { accountDeletionRequestedAt: 1, _id: 1 } }, + { $lookup: { from: AccountDeletion.collection.name, localField: '_id', + foreignField: '_id', as: 'cleanupReceipt' } }, + { $match: { 'cleanupReceipt.0': { $exists: false } } }, + { $limit: ACCOUNT_DELETION_SWEEP_BATCH }, + { $unset: 'cleanupReceipt' }, + ]); + for (const user of stranded) { + // eslint-disable-next-line no-await-in-loop + await renewLease(); + try { + // eslint-disable-next-line no-await-in-loop + await establishCleanupReceipt(user, false); + } catch (error) { + errors.push(error); + failedIds.push(String(user._id)); + } + } + const receipts = await AccountDeletion.find().sort({ lastSweptAt: 1, _id: 1 }) + .limit(ACCOUNT_DELETION_SWEEP_BATCH).select('_id').lean(); + for (const receipt of receipts) { + // Renew between receipts. If this worker lost its lease, stop starting + // work; expiry recovers a crashed worker and deletes remain idempotent. + // eslint-disable-next-line no-await-in-loop + await renewLease(); + const userId = String(receipt._id); + try { + // eslint-disable-next-line no-await-in-loop + await revokeOwnedAccess(userId); + // eslint-disable-next-line no-await-in-loop + await anonymizeChatAuthor(userId); + // eslint-disable-next-line no-await-in-loop + await deleteOwnedUploads(userId); + } catch (error) { + errors.push(error); + failedIds.push(userId); + } + // Rotate failed receipts too: one persistent failure cannot starve the + // remaining queue. Keep the intent, so every later round retries it. + try { + // eslint-disable-next-line no-await-in-loop + await AccountDeletion.updateOne({ _id: receipt._id }, { $set: { lastSweptAt: new Date() } }); + } catch (error) { + errors.push(error); + failedIds.push(userId); + } + } + if (errors.length) throw new AggregateError(errors, `Account deletion cleanup failed: ${failedIds.join(',')}`); + } finally { + await SchedulerHeartbeatLease.deleteOne({ _id: SWEEP_LEASE_ID, ownerId }); + } +}; + +/** Human deletion pauses owned runtimes, preserving independent agent User/memory rows. */ +export const deleteOwnAccount = async ( + userId: string, + bearer: string, + password: unknown, +): Promise => { + const user = await User.findById(userId); + if (!user || user.isBot) { + throw new AccountDeletionError(403, 'HUMAN_ACCOUNT_REQUIRED', 'Only human accounts can use account deletion.'); + } + if (user.password) { + if (typeof password !== 'string' || !password || password.length > 1024) { + throw new AccountDeletionError(401, 'PASSWORD_REQUIRED', 'Enter your password to delete your account.'); + } + if (!await user.comparePassword(password)) { + throw new AccountDeletionError(401, 'PASSWORD_REQUIRED', 'Incorrect password.'); + } + } else { + const claims = jwt.verify(bearer, process.env.JWT_SECRET!) as jwt.JwtPayload; + const age = Math.floor(Date.now() / 1000) - claims.oauthAuthenticatedAt; + if (String(claims.id) !== userId || !Number.isSafeInteger(claims.oauthAuthenticatedAt) + || age < 0 || age > 300) { + throw new AccountDeletionError(401, 'REAUTH_REQUIRED', + 'Sign in again with your OAuth provider, then confirm deletion within five minutes.'); + } + } + // Admins hand off their role before deleting. A separate count followed by + // deletion lets two admins both pass and remove the final administrators. + if (user.role === 'admin') { + throw new AccountDeletionError(409, 'ADMIN_HANDOFF_REQUIRED', + 'Transfer administration and ask another admin to make this a member account before deleting it.'); + } + + await establishCleanupReceipt(user); + await revokeOwnedAccess(userId); + + await deleteOwnedUploads(userId); + + await anonymizeChatAuthor(userId); + await User.deleteOne({ _id: userId, isBot: { $ne: true }, role: { $ne: 'admin' } }); + disconnectUserSessionSockets(userId); +}; diff --git a/backend/services/accountDeletionState.ts b/backend/services/accountDeletionState.ts new file mode 100644 index 0000000000..6484369360 --- /dev/null +++ b/backend/services/accountDeletionState.ts @@ -0,0 +1,9 @@ +import User from '../models/User'; +import AccountDeletion from '../models/AccountDeletion'; + +/** The live marker covers intent creation; the receipt survives removal of User. */ +export const isAccountDeleting = async (userId: unknown): Promise => { + if (!userId) return false; + return Boolean(await AccountDeletion.exists({ _id: userId }) + || await User.exists({ _id: userId, accountDeletionRequestedAt: { $exists: true } })); +}; diff --git a/backend/services/objectStore/ObjectStore.ts b/backend/services/objectStore/ObjectStore.ts index 4f4846d4dc..601dc8c9a4 100644 --- a/backend/services/objectStore/ObjectStore.ts +++ b/backend/services/objectStore/ObjectStore.ts @@ -49,4 +49,7 @@ export interface ObjectStore { /** Remove bytes by `key`. No-op if the key does not exist. */ delete(key: string): Promise; + + /** Delete an ownership namespace, including writes that lost their metadata. */ + deletePrefix(prefix: string): Promise; } diff --git a/backend/services/objectStore/drivers/mongoDriver.ts b/backend/services/objectStore/drivers/mongoDriver.ts index 76e4658bac..77a7bdf75b 100644 --- a/backend/services/objectStore/drivers/mongoDriver.ts +++ b/backend/services/objectStore/drivers/mongoDriver.ts @@ -45,6 +45,13 @@ export class MongoObjectStore implements ObjectStore { }; } + async deletePrefix(prefix: string): Promise { + if (!prefix) throw new Error('An object deletion prefix is required'); + // Keys are indexed. The range matches this exact prefix, not a regex + // supplied by a caller; upload namespaces contain only ASCII characters. + await MediaObject.deleteMany({ key: { $gte: prefix, $lt: `${prefix}\uffff` } }); + } + async delete(key: string): Promise { await MediaObject.deleteOne({ key }); } diff --git a/backend/services/podContextService.ts b/backend/services/podContextService.ts index 83e669d6e8..8cc067cda4 100644 --- a/backend/services/podContextService.ts +++ b/backend/services/podContextService.ts @@ -404,13 +404,18 @@ class PodContextService { const memberDocs = await User.find({ _id: { $in: memberIds } }) .select('_id username isBot botMetadata') .lean(); - members = (memberDocs as Array>).map((u) => ({ - name: u.isBot - ? resolveAgentDisplayLabel(u, (u.username as string) || 'agent') - : ((u.username as string) || 'member'), - isAgent: !!u.isBot, - self: toObjectIdString(u._id) === selfId, - })); + const usersById = new Map((memberDocs as Array>) + .map((user) => [toObjectIdString(user._id), user])); + members = memberIds.map((id) => { + const user = usersById.get(id); + return { + name: user?.isBot + ? resolveAgentDisplayLabel(user, (user.username as string) || 'agent') + : (user ? ((user.username as string) || 'member') : 'Deleted user'), + isAgent: !!user?.isBot, + self: id === selfId, + }; + }); } // Files a human uploaded into this pod, so an agent orienting via diff --git a/backend/services/podMemberPopulate.ts b/backend/services/podMemberPopulate.ts new file mode 100644 index 0000000000..3a1295c529 --- /dev/null +++ b/backend/services/podMemberPopulate.ts @@ -0,0 +1,10 @@ +// Historical DM membership survives account deletion. Mongoose normally drops +// missing User refs from populated arrays; keep the opaque id and a safe label +// so every pod read preserves the pair without reviving the deleted account. +export const podMemberPopulate = (select: string) => ({ + path: 'members', + select, + transform: (member: unknown, id: unknown) => member || { + _id: id, username: 'Deleted user', profilePicture: null, isBot: false, + }, +}); diff --git a/backend/services/schedulerService.ts b/backend/services/schedulerService.ts index 3342b88da1..2f0e0069c5 100644 --- a/backend/services/schedulerService.ts +++ b/backend/services/schedulerService.ts @@ -550,6 +550,25 @@ class SchedulerService { { scheduled: false, timezone: 'UTC' }, ); + // A late upload can outlive the deletion request and fail its own cleanup. + // Durable intents contain only opaque user ids; this sweep can retry after + // the User row is gone. Deletes are idempotent across backend replicas. + const accountDeletionUploadJob: CronJob = cron.schedule( + '* * * * *', + async () => { + try { + // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports + const { sweepAccountDeletions } = require('./accountDeletionService'); + await sweepAccountDeletions(); + } catch (error) { + const errorName = error instanceof AggregateError ? error.message + : error instanceof Error ? error.name : 'Unknown error'; + console.error('[account-deletion] sweep failed:', errorName); + } + }, + { scheduled: false, timezone: 'UTC' }, + ); + const spawnCredentialReapJob: CronJob = cron.schedule( '*/5 * * * *', async () => { @@ -627,6 +646,7 @@ class SchedulerService { decisionCardReconcileJob, approvalToolCallSweepJob, spawnCredentialReapJob, + accountDeletionUploadJob, spendAlarmJob, ...(process.env.SYNTHETIC_ACCOUNT_PURGE === 'true' ? [syntheticAccountPurgeJob] : []), ]; diff --git a/backend/services/sessionAccountService.ts b/backend/services/sessionAccountService.ts index 1465a2e508..7fada24473 100644 --- a/backend/services/sessionAccountService.ts +++ b/backend/services/sessionAccountService.ts @@ -24,7 +24,7 @@ import User from '../models/User'; * nothing, which is why the fields are selected here rather than at each call * site. */ -export type SessionAccountRefusal = 'missing' | 'banned' | 'bot'; +export type SessionAccountRefusal = 'missing' | 'banned' | 'bot' | 'deleting'; export type SessionTokenRefusal = SessionAccountRefusal | 'revoked'; export interface SessionAccount { @@ -35,12 +35,14 @@ export interface SessionAccount { email?: string; role?: string; tokenVersion?: number; + accountDeletionRequestedAt?: Date; } export const loadSessionAccount = async (userId: string): Promise => { // Express auth also needs identity fields for the same req.user shape as // API-token auth. Keep one live-row read for all session verifiers. - const account = await User.findById(userId).select('banned isBot username email role tokenVersion').lean(); + const account = await User.findById(userId) + .select('banned isBot username email role tokenVersion accountDeletionRequestedAt').lean(); return (account as SessionAccount | null) || null; }; @@ -63,5 +65,6 @@ export function sessionRefusal( if (claims && (claims.tokenVersion === undefined ? 0 : claims.tokenVersion) !== (account.tokenVersion ?? 0)) { return 'revoked'; } + if (account.accountDeletionRequestedAt) return 'deleting'; return null; } diff --git a/backend/services/sessionTokenService.ts b/backend/services/sessionTokenService.ts index 0ce3b9f8ce..60d664eefa 100644 --- a/backend/services/sessionTokenService.ts +++ b/backend/services/sessionTokenService.ts @@ -13,6 +13,7 @@ export const issueSessionToken = ( user: { _id: unknown; tokenVersion?: number }, sessionStartedAt?: number, authenticatedVersion?: number, + oauthAuthenticatedAt?: number, ): string | null => { const now = Math.floor(Date.now() / 1000); const startedAt = sessionStartedAt === undefined ? now : sessionStartedAt; @@ -23,5 +24,6 @@ export const issueSessionToken = ( id: user._id, tokenVersion: authenticatedVersion ?? user.tokenVersion ?? 0, sessionStartedAt: startedAt, + ...(oauthAuthenticatedAt === undefined ? {} : { oauthAuthenticatedAt }), }, process.env.JWT_SECRET!, { expiresIn: Math.min(SESSION_TOKEN_SECONDS, remaining) }); }; diff --git a/backend/services/toolBrokerService.ts b/backend/services/toolBrokerService.ts index c99176cb9c..931f5e3e53 100644 --- a/backend/services/toolBrokerService.ts +++ b/backend/services/toolBrokerService.ts @@ -680,12 +680,14 @@ const HOSTED_OWNER_REFUSALS: Record = { banned: 'connection_owner_banned', missing: 'connection_owner_missing', bot: 'connection_owner_bot', + deleting: 'connection_owner_deleting', }; const HOSTED_OWNER_MESSAGES: Record = { banned: 'the person who connected this connection is suspended', missing: 'the person who connected this connection no longer exists', bot: 'this connection is owned by a bot account, which cannot be its granter', + deleting: 'the person who connected this connection is deleting their account', }; const assertHostedOwnerUsable = async (ownerUserId?: string): Promise => { diff --git a/docs/api/account-deletion.md b/docs/api/account-deletion.md new file mode 100644 index 0000000000..aaf8f10341 --- /dev/null +++ b/docs/api/account-deletion.md @@ -0,0 +1,180 @@ +# Self-serve account deletion + +`DELETE /api/users/me` accepts a browser JWT and a JSON body: + +```json +{ "confirmation": "DELETE", "password": "the current password" } +``` + +A successful request returns `204`. A password account must supply its current +password, including accounts that also have a linked OAuth provider. An OAuth-only +account must complete a new provider sign-in and confirm within five minutes. +Only the one-time OAuth exchange stamps `oauthAuthenticatedAt` on its signed JWT; +ordinary JWT issuance and session refresh do not supply that proof. API tokens, +device tokens and agent runtime credentials cannot call this route. + +The human upload endpoint is also limited to sixty requests per fifteen minutes +per client address, ahead of authentication and multipart parsing. + +The endpoint is limited to ten attempts per fifteen minutes per client address. +Administrators must hand off their role before deletion: another administrator +must first make the deleting account a member. Concurrent admin deletion +requests are refused without changing either account. The deletion marker is +conditional on the account still being a non-admin, and role updates refuse a +marked account, so a concurrent promotion and deletion cannot cross that boundary. + +## Data and identity rules + +- The human's MongoDB `User` row is deleted. Its embedded API/device tokens no + longer authenticate, and browser JWT verification refuses the missing account. + Existing browser sockets are disconnected. +- Every `File` owned by that human is removed, including legacy inline payloads. + Its object-store bytes are deleted first, using `fileName` as the storage key. + New uploads use the ownership namespace `user--`, which lets deletion + find bytes even if their metadata save failed. + Ownership comes from `File.uploadedBy`; `MediaObject` has no ownership column. + This includes avatars and uploads shared into pods. Other users' and agents' + uploads remain. +- Authorized device-login and OAuth-login state rows for the human are removed. + Owned `AgentCredential` records and their descendants are revoked; machine + registrations are removed. Agent credentials owned by other humans remain. +- Active runtime installations made by the human are paused. Their records, + independent agent `User` identities, memory and pod memberships survive + (ADR-001). Installations made by other humans remain active. Legacy agent + tokens that carry no human ownership are not reclassified as the human's token. +- The deleted human is removed from ordinary pods' `Pod.members` and PG + `pod_members` mirror. Strict 1:1 `agent-room` and `agent-dm` pods retain both + historical member IDs in both stores; removing one would corrupt their shape. + The deleted account cannot authenticate, its PG author is anonymized, and its + owned runtime installations are paused. This preserves conversation history, + not an active human account. Pods have no separate archive lifecycle field. + Pod reads and runtime context render a missing member as `Deleted user`; + membership and agent-DM observer fan-out still use the historical IDs. + Pods they created retain only the opaque historical `createdBy` reference; no + ownership transfer occurs. Other humans' and agents' memberships remain. +- Shared messages and posts remain. PostgreSQL's author row becomes a `Deleted + user` tombstone with no avatar so historical chat joins retain an author without + preserving its display name. Account deletion does not scrub personal details + a person wrote into shared message text. Attachments they uploaded are removed + even when shared in someone else's message; retained text may have a missing attachment. +- Any linked Stripe subscription is canceled before the account's billing + linkage is removed. Cancellation failure refuses deletion rather than leaving + an unreachable recurring charge. Stripe is an external billing boundary; + cancellation is mocked in the service tests. + +## Failures and retries + +Cleanup spans MongoDB, the object store, PostgreSQL and Stripe, so it is not a +single transaction. After re-authentication, an atomic non-admin claim sets +`accountDeletionRequestedAt` before calling Stripe. The marker blocks promotion, +new uploads and owned runtime authentication during preflight. Stripe cancellation +has a 30-second timeout and no automatic network retry. Failure releases only +that attempt's marker and preserves credentials, uploads and the human row. +A crashed preflight can be reclaimed after five minutes. A timeout can have an +unknown provider outcome; retry uses the same account-and-subscription idempotency +key. After successful preflight, the durable cleanup receipt is created. Retries +with a receipt skip billing cancellation. Upload requests already +in flight recheck the marker after saving metadata and discard their upload if +deletion has begun or the account is gone. + +An `AccountDeletion` receipt retains the opaque user id, creation time and last +attempt time. +A scheduler tick runs every minute under a renewable two-minute cluster lease. +Before receipt cleanup, it recovers at most 100 stale authorized user markers +whose receipt write failed. It repeats billing preflight with the same idempotency +key before creating a receipt; it never cleans uploads before billing succeeds. +Fresh claims are excluded. Failed recovered preflights retain their marker and +rotate by claim time for another attempt. A retained human can finish deletion +through a re-authenticated browser retry after cleanup succeeds. +Each tick processes at most 100 receipts, ordered by the oldest attempt. Failed +receipts also rotate to the back of the queue. One cleanup failure does not stop +other receipts: the sweep reports their failures as an aggregate after the batch, +with opaque receipt ids in the scheduler log. Lease expiry recovers a crashed +worker; operations are idempotent if an expired worker finishes a late operation. + +Each attempt retries access revocation, chat-author redaction and upload cleanup, +including bytes in its namespace that have no `File` record. The receipt survives User +deletion: a late upload that fails its own cleanup is removed on a subsequent +sweep when storage recovers. These receipts deliberately have no TTL so a delayed +write cannot outlive its cleanup intent. They contain no profile, email or token. +Processing progress stores only `lastSweptAt`. A busy queue can take multiple ticks to revisit a receipt. + +Runtime-token issuance checks both the live deletion marker and the durable +receipt before mint/backfill and after its writes. A late write owned by the +deleting account is revoked from all token stores before issuance refuses. An +existing token owned by someone else is preserved. Runtime authentication also +refuses credentials whose owner has a deletion marker or receipt, covering the +interval before the issuer completes its post-write check. + +Bytes are removed before their `File` metadata. A failure in the deletion +request's own storage or PG cleanup returns +`503` and retains the human row so a re-authenticated retry can finish. Already +removed objects are safe to delete again. Owned API/device credentials are +revoked during cleanup; the browser JWT is refused for ordinary HTTP, socket handshakes/packets, +attachment access and hosted connector use while the deletion marker remains. +Existing browser sockets are disconnected during access cleanup. Two canonical +HTTP routes remain: re-authenticated `DELETE /api/users/me`, and read-only +`GET /api/auth/user`, which returns only id, username, email, role and +`accountDeletionPending: true` so AuthContext can reopen Settings after reload or +fresh OAuth. This bootstrap grants no pod/profile/refresh access. Password or +fresh OAuth proof is still required on every deletion retry. A retry after the OAuth freshness window requires another +provider sign-in. Subscription cancellation uses an account-and-subscription-specific idempotency +key. This does not replace provider retention policies or remove billing audit +records and backups. + +Relevant errors: `CONFIRMATION_REQUIRED` (400), `PASSWORD_REQUIRED` or +`REAUTH_REQUIRED` (401), `BROWSER_SESSION_REQUIRED` (403), `ADMIN_HANDOFF_REQUIRED` or `ACCOUNT_CHANGED` (409). + +## Verification + +`backend/__tests__/service/accountDeletion.test.js` exercises real MongoDB and +PostgreSQL when run with `INTEGRATION_TEST=true`, including upload bytes and +metadata removal, retained messages and anonymized author joins, agent +identity/memory continuity, token revocation, failed-cleanup retries and an +upload that crosses deletion, including storage cleanup failure and a failed +metadata save after the human row is gone. The positive OAuth case consumes a real one-time +exchange code rather than constructing the accepted JWT claim in the fixture. + +## Request quotas + +Deletion permits ten attempts per user per fifteen minutes; human uploads permit +sixty. These quotas run after authentication and use the durable user id, so +rotating sessions or forged forwarding headers cannot reset them. Before auth, a coarse quota keys on a SHA-256 digest of the presented bearer +(120 deletion / 6,000 upload attempts per fifteen minutes), never its plaintext. +Requests without credentials share a separate anonymous bucket. Exhausting that +bucket cannot block an authenticated account. Rotating invalid credentials can +evade this coarse bound; it does not replace ingress abuse protection. The +post-auth quota remains keyed by stable user id across valid session rotation. +No quota keys on the shared proxy socket or untrusted forwarding headers. + +Lily ruled on 2026-10-25 to preserve agent identities and leave them detached. +Original ownership remains, owned runtimes pause and owned credentials are revoked. +Self-serve re-attachment requires the original owner, so it is unavailable after +that owner is deleted. No ownership transfer or administrator recovery is introduced. + +## Remaining bounds + +Receipts deliberately retain the opaque id after cleanup because not every late +producer is fenced. With N retained receipts, a healthy 100-per-minute sweep can +take roughly ceil(N/100) ticks per full revisit; work grows with lifetime +deletions. Retiring receipts requires a proven producer horizon, not an arbitrary +TTL. TASK-004 tracks that capacity/retirement work. + +Administrator role, ban and admin-delete mutations now share the non-expiring, +majority-acknowledged lock introduced by #2742 (TASK-003). Actor authorization and +administrator recount happen inside it. Role updates also retain the atomic +`accountDeletionRequestedAt`-absent predicate: self-serve deletion claims do not +take the global lock, so the lock alone cannot prevent a late promotion of a +marked account. Tests exercise both orders through the actual handlers: +promotion wins before the deletion claim and prevents billing cancellation, or +deletion marks after the role handler reads its target and promotion returns 409. +See [the lock runbook](../runbooks/admin-user-mutation-lock.md) for uncertain-write +recovery and the mixed-version rollout constraint. Synthetic purge DM cleanup +remains a separate TASK-005 follow-up. + +The pre-existing administrative hard-delete endpoint remains separate from this +lifecycle. Its `target.deleteOne()` does not condition on the self-deletion marker, +so the global admin lock alone does not coordinate it with an in-flight +self-deletion claim. TASK-006 tracks a controlled reproduction and atomic +coordination/recoverable cleanup; this PR does not fix that accepted nonblocking +finding. It is distinct from TASK-005's synthetic-purge DM cleanup. diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js index d543067f5b..4294b18489 100644 --- a/frontend/.eslintrc.js +++ b/frontend/.eslintrc.js @@ -50,6 +50,7 @@ module.exports = { 'src/v2/components/V2ResetPassword.tsx', 'src/v2/components/V2OAuthButtons.tsx', 'src/v2/components/V2OAuthComplete.tsx', + 'src/v2/components/V2DeleteAccount.tsx', 'src/v2/components/V2InviteRedeem.tsx', 'src/components/RegistrationInviteRequired.tsx', 'src/components/VerifyEmail.tsx', diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index aa1d279865..da3b5052eb 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -108,6 +108,18 @@ "removeError": "Couldn’t remove this skill." }, "settings": { + "deleteAccount": { + "title": "Delete account", + "effects": "This permanently deletes your account and uploads and cancels any active Stripe subscription. Shared messages are retained. Agent runtimes you installed are paused. Their identities and memory survive. This cannot be undone.", + "password": "Password (if your account has one)", + "confirmation": "Type DELETE to confirm", + "oauthHint": "If you sign in with Google or GitHub, you may need to sign in again with the same account before deleting.", + "signInAgain": "Sign in again with Google or GitHub, then return here to confirm", + "confirm": "Permanently delete account", + "deleting": "Deleting…", + "cancel": "Cancel", + "failed": "Couldn’t delete your account. Try again." + }, "sessions": { "signOutEverywhere": "Sign out everywhere", "confirm": "Sign out of every browser session, including this one?", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 1395badf2a..1ece2652d4 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -108,6 +108,18 @@ "removeError": "无法移除此技能。" }, "settings": { + "deleteAccount": { + "title": "删除账户", + "effects": "这将永久删除你的账户和上传的文件,并取消所有有效的 Stripe 订阅。共享消息会保留。你安装的智能体运行时会暂停。它们的身份和记忆会保留。此操作无法撤销。", + "password": "密码(如果账户设有密码)", + "confirmation": "输入 DELETE 以确认", + "oauthHint": "如果你使用 Google 或 GitHub 登录,删除前可能需要使用同一账户重新登录。", + "signInAgain": "使用 Google 或 GitHub 重新登录,然后返回此处确认", + "confirm": "永久删除账户", + "deleting": "正在删除…", + "cancel": "取消", + "failed": "无法删除你的账户。请重试。" + }, "sessions": { "signOutEverywhere": "退出所有浏览器会话", "confirm": "退出所有浏览器会话,包括当前会话?", diff --git a/frontend/src/v2/__tests__/V2DeleteAccount.test.tsx b/frontend/src/v2/__tests__/V2DeleteAccount.test.tsx new file mode 100644 index 0000000000..8d82900f29 --- /dev/null +++ b/frontend/src/v2/__tests__/V2DeleteAccount.test.tsx @@ -0,0 +1,147 @@ +import React from 'react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import axios from '../../utils/axiosConfig'; +import { AuthContext } from '../../context/AuthContext'; +import i18n, { i18nReady } from '../../i18n'; +import V2DeleteAccount from '../components/V2DeleteAccount'; +import V2Login from '../components/V2Login'; + +jest.mock('../../utils/axiosConfig', () => ({ + __esModule: true, + default: { delete: jest.fn(), get: jest.fn() }, +})); + +const logout = jest.fn(); +const auth = { + currentUser: { _id: 'u1', username: 'me', email: 'me@example.com' }, + user: null, token: 'jwt', loading: false, error: null, isAuthenticated: true, + register: jest.fn(), login: jest.fn(), signInWithToken: jest.fn(), logout, updateProfile: jest.fn(), +}; + +const renderDeletion = () => render( + + + + } /> + } /> + + + , +); + +const openDeletion = () => fireEvent.click(screen.getByRole('button', { name: 'Delete account' })); +const confirmDeletion = (value = 'DELETE') => fireEvent.change(screen.getByLabelText('Type DELETE to confirm'), { target: { value } }); +const submit = () => fireEvent.click(screen.getByRole('button', { name: 'Permanently delete account' })); + +beforeEach(async () => { + jest.resetAllMocks(); + localStorage.clear(); + await i18nReady; + await i18n.changeLanguage('en'); + (axios.get as jest.Mock).mockResolvedValue({ data: { providers: [{ id: 'github', label: 'GitHub' }] } }); +}); + +test('requires an exact deliberate confirmation, explains retention, and cancel clears sensitive fields', () => { + renderDeletion(); + expect(screen.queryByRole('form')).not.toBeInTheDocument(); + openDeletion(); + expect(screen.getByText(/permanently deletes your account and uploads/)).toHaveTextContent('cancels any active Stripe subscription. Shared messages are retained. Agent runtimes you installed are paused. Their identities and memory survive.'); + expect(screen.getByLabelText('Type DELETE to confirm')).toHaveFocus(); + confirmDeletion('delete'); + expect(screen.getByRole('button', { name: 'Permanently delete account' })).toBeDisabled(); + // Form submission itself must enforce the guard, including Enter/programmatic submits. + fireEvent.submit(screen.getByRole('form', { name: 'Delete account' })); + expect(axios.delete).not.toHaveBeenCalled(); + fireEvent.change(screen.getByLabelText('Password (if your account has one)'), { target: { value: 'secret' } }); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(screen.getByRole('button', { name: 'Delete account' })).toHaveFocus(); + openDeletion(); + expect(screen.getByLabelText('Password (if your account has one)')).toHaveValue(''); + expect(screen.getByLabelText('Type DELETE to confirm')).toHaveValue(''); + expect(logout).not.toHaveBeenCalled(); +}); + +test('sends the password in the DELETE body and logs out only after a 204; blocks duplicate submits', async () => { + let resolve!: (value: unknown) => void; + (axios.delete as jest.Mock).mockReturnValue(new Promise((done) => { resolve = done; })); + renderDeletion(); + openDeletion(); + confirmDeletion(); + fireEvent.change(screen.getByLabelText('Password (if your account has one)'), { target: { value: 'correct password' } }); + submit(); + fireEvent.submit(screen.getByRole('form', { name: 'Delete account' })); + expect(axios.delete).toHaveBeenCalledTimes(1); + expect(axios.delete).toHaveBeenCalledWith('/api/users/me', { data: { password: 'correct password', confirmation: 'DELETE' } }); + expect(logout).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: 'Deleting…' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled(); + await act(async () => { resolve({ status: 204, data: '' }); }); + expect(logout).toHaveBeenCalledTimes(1); +}); + +test('keeps the session on failure, clears the error on retry and omits an unused password', async () => { + (axios.delete as jest.Mock).mockRejectedValueOnce(new Error('offline')).mockResolvedValueOnce({ data: {} }); + renderDeletion(); + openDeletion(); + confirmDeletion(); + submit(); + expect(await screen.findByRole('alert')).toHaveTextContent('Couldn’t delete your account. Try again.'); + expect(logout).not.toHaveBeenCalled(); + submit(); + await waitFor(() => expect(logout).toHaveBeenCalledTimes(1)); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(axios.delete).toHaveBeenLastCalledWith('/api/users/me', { data: { confirmation: 'DELETE' } }); +}); + +test('PASSWORD_REQUIRED focuses a required password field and allows correction', async () => { + (axios.delete as jest.Mock).mockRejectedValueOnce({ response: { data: { code: 'PASSWORD_REQUIRED', error: 'Enter your password.' } } }) + .mockResolvedValueOnce({ data: {} }); + renderDeletion(); + openDeletion(); + confirmDeletion(); + submit(); + expect(await screen.findByRole('alert')).toHaveTextContent('Enter your password.'); + const password = screen.getByLabelText('Password (if your account has one)'); + expect(password).toBeRequired(); + expect(password).toHaveFocus(); + expect(screen.getByRole('button', { name: 'Permanently delete account' })).toBeDisabled(); + expect(logout).not.toHaveBeenCalled(); + fireEvent.change(password, { target: { value: 'corrected' } }); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + submit(); + await waitFor(() => expect(logout).toHaveBeenCalledTimes(1)); +}); + +test('REAUTH_REQUIRED sends the existing OAuth login flow back to Settings without logging out or auto-deleting', async () => { + (axios.delete as jest.Mock).mockRejectedValueOnce({ response: { data: { code: 'REAUTH_REQUIRED', error: 'Sign in again.' } } }); + renderDeletion(); + openDeletion(); + confirmDeletion(); + submit(); + expect(await screen.findByRole('alert')).toHaveTextContent('Sign in again.'); + const link = screen.getByRole('link', { name: /Sign in again with Google or GitHub/ }); + expect(link).toHaveAttribute('href', '/v2/login?next=/v2/settings'); + fireEvent.click(link); + const oauth = await screen.findByRole('link', { name: /Continue with GitHub/ }); + expect(new URL(oauth.getAttribute('href')!, 'http://localhost').searchParams.get('next')).toBe('/v2/settings'); + expect(logout).not.toHaveBeenCalled(); + expect(axios.delete).toHaveBeenCalledTimes(1); +}); + +test.each([ + [401, 'INVALID_PASSWORD', 'Incorrect password.'], + [409, 'ADMIN_HANDOFF_REQUIRED', 'Hand off administration before deleting your account.'], + [403, 'BROWSER_SESSION_REQUIRED', 'Use a browser session to delete your account.'], + [503, 'CLEANUP_FAILED', 'Cleanup could not finish. Try again.'], +])('shows a %s %s rejection and keeps confirmation available for retry', async (status, code, error) => { + (axios.delete as jest.Mock).mockRejectedValueOnce({ response: { status, data: { code, error } } }); + renderDeletion(); + openDeletion(); + confirmDeletion(); + submit(); + expect(await screen.findByRole('alert')).toHaveTextContent(error); + expect(screen.getByLabelText('Type DELETE to confirm')).toHaveValue('DELETE'); + expect(screen.getByRole('button', { name: 'Permanently delete account' })).toBeEnabled(); + expect(logout).not.toHaveBeenCalled(); +}); diff --git a/frontend/src/v2/__tests__/V2SettingsPage.test.tsx b/frontend/src/v2/__tests__/V2SettingsPage.test.tsx index 472c15b1c8..9ba2c8a308 100644 --- a/frontend/src/v2/__tests__/V2SettingsPage.test.tsx +++ b/frontend/src/v2/__tests__/V2SettingsPage.test.tsx @@ -121,6 +121,7 @@ describe('V2SettingsPage', () => { renderSettings(); expect(screen.getByRole('heading', { name: 'Settings' })).toBeInTheDocument(); + expect(within(screen.getByRole('region', { name: 'Account' })).getByRole('button', { name: 'Delete account' })).toBeInTheDocument(); for (const section of [ { id: 'account', title: 'Account' }, { id: 'plan', title: 'Plan' }, diff --git a/frontend/src/v2/components/V2DeleteAccount.tsx b/frontend/src/v2/components/V2DeleteAccount.tsx new file mode 100644 index 0000000000..e02ab39be6 --- /dev/null +++ b/frontend/src/v2/components/V2DeleteAccount.tsx @@ -0,0 +1,138 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import axios from '../../utils/axiosConfig'; +import { useAuth } from '../../context/AuthContext'; + +const V2DeleteAccount: React.FC = () => { + const { logout } = useAuth(); + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const [confirmation, setConfirmation] = useState(''); + const [password, setPassword] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [reauthRequired, setReauthRequired] = useState(false); + const [passwordRequired, setPasswordRequired] = useState(false); + const pending = useRef(false); + const triggerRef = useRef(null); + const passwordRef = useRef(null); + const confirmationRef = useRef(null); + + useEffect(() => { if (open) confirmationRef.current?.focus(); }, [open]); + useEffect(() => { + if (passwordRequired && !busy) passwordRef.current?.focus(); + }, [passwordRequired, busy]); + + const cancel = () => { + setOpen(false); + setPassword(''); + setConfirmation(''); + setError(null); + setReauthRequired(false); + setPasswordRequired(false); + triggerRef.current?.focus(); + }; + + const remove = async (event: React.FormEvent) => { + event.preventDefault(); + if (pending.current || confirmation !== 'DELETE' || (passwordRequired && !password)) return; + pending.current = true; + setBusy(true); + setError(null); + setReauthRequired(false); + try { + await axios.delete('/api/users/me', { + data: { confirmation: 'DELETE', ...(password ? { password } : {}) }, + }); + } catch (requestError) { + const data = (requestError as { response?: { data?: { code?: string; error?: string } } })?.response?.data; + setError(data?.error || t('settings.deleteAccount.failed')); + setReauthRequired(data?.code === 'REAUTH_REQUIRED'); + if (data?.code === 'PASSWORD_REQUIRED') { + setPasswordRequired(true); + } + return; + } finally { + pending.current = false; + setBusy(false); + } + setPassword(''); + logout(); + }; + + return ( +
+ + {open && ( +
{ void remove(event); }} + > +

{t('settings.deleteAccount.effects')}

+
+ + +
+

{t('settings.deleteAccount.oauthHint')}

+ {error &&

{error}

} + {reauthRequired && ( +

+ {t('settings.deleteAccount.signInAgain')} +

+ )} +
+ + +
+
+ )} +
+ ); +}; + +export default V2DeleteAccount; diff --git a/frontend/src/v2/components/V2SettingsPage.tsx b/frontend/src/v2/components/V2SettingsPage.tsx index 5ffbdcaa29..9402504c15 100644 --- a/frontend/src/v2/components/V2SettingsPage.tsx +++ b/frontend/src/v2/components/V2SettingsPage.tsx @@ -7,6 +7,7 @@ import { useAuth } from '../../context/AuthContext'; import AppsManagement from '../../components/AppsManagement'; import V2BillingPanel from './V2BillingPanel'; import V2DevicesPanel from './V2DevicesPanel'; +import V2DeleteAccount from './V2DeleteAccount'; import V2Avatar from './V2Avatar'; import V2AvatarCropDialog from './V2AvatarCropDialog'; import { nextPaperAvatarPreset } from '../utils/avatarPresets'; @@ -540,7 +541,10 @@ const V2SettingsPage: React.FC = () => {
- + + + +