diff --git a/backend/__tests__/service/syntheticPurgeDm.test.js b/backend/__tests__/service/syntheticPurgeDm.test.js new file mode 100644 index 0000000000..d03713ba4b --- /dev/null +++ b/backend/__tests__/service/syntheticPurgeDm.test.js @@ -0,0 +1,178 @@ +const mongoose = require('mongoose'); +const { newDb } = require('pg-mem'); +const { setupMongoDb, closeMongoDb, clearMongoDb } = require('../utils/testUtils'); +let mockPool; +jest.mock('../../config/db-pg', () => ({ pool: { query: (...args) => mockPool.query(...args) } })); +const User = require('../../models/User'); +const Pod = require('../../models/Pod'); +const AgentCredential = require('../../models/AgentCredential'); +const Message = require('../../models/Message'); +const AgentMemory = require('../../models/AgentMemory'); +const { AgentInstallation } = require('../../models/AgentRegistry'); +const { purgeSyntheticAccounts } = require('../../services/syntheticAccountPurgeService'); +const id = () => new mongoose.Types.ObjectId(); +const OLD = new Date('2026-10-15'); +const NOW = new Date('2026-10-25'); +const insertUser = async (fields) => { const row = { _id: id(), email: `${id()}@fixture.test`, createdAt: OLD, ...fields }; await User.collection.insertOne(row); return row; }; + +describe('Synthetic purge DM preservation', () => { + let pool; let schema; let adminPool; + beforeAll(setupMongoDb); + afterAll(closeMongoDb); + beforeEach(async () => { + if (process.env.INTEGRATION_TEST === 'true') { + const { Pool } = require('pg'); + const options = { host: process.env.PG_HOST, port: Number(process.env.PG_PORT), database: process.env.PG_DATABASE, + user: process.env.PG_USER, password: process.env.PG_PASSWORD, ssl: false }; + adminPool = new Pool(options); + schema = `purge_dm_${id().toString()}`; + await adminPool.query(`CREATE SCHEMA ${schema}`); + pool = new Pool({ ...options, options: `-c search_path=${schema}` }); + } else { + const Pool = newDb().adapters.createPg().Pool; pool = new Pool(); + } + mockPool = pool; + await pool.query('CREATE TABLE users (_id varchar(24) PRIMARY KEY)'); + await pool.query('CREATE TABLE pods (id varchar(24) PRIMARY KEY, type text)'); + await pool.query('CREATE TABLE pod_members (pod_id varchar(24) REFERENCES pods(id) ON DELETE CASCADE, user_id varchar(24))'); + await pool.query('CREATE TABLE messages (pod_id varchar(24))'); + await pool.query('CREATE TABLE message_claims (pod_id varchar(24))'); + }); + afterEach(async () => { await clearMongoDb(); await pool.end(); + if (adminPool) { await adminPool.query(`DROP SCHEMA ${schema} CASCADE`); await adminPool.end(); adminPool = null; } }); + + it.each(['agent-room', 'agent-dm'].flatMap((type) => [false, true].map((owned) => [type, owned]))) + ('retains both stores and independent agents in %s (created by synthetic=%s)', async (type, owned) => { + const human = await insertUser({ username: 'eng-smoke-journey-1015124709', email: 'journey@ops.commonly.invalid', isBot: false }); + const agent = await insertUser({ username: 'independent', isBot: true }); + const other = await insertUser({ username: 'other', isBot: false }); + const pod = { _id: id(), name: 'Historical DM', type, createdBy: owned ? human._id : other._id, members: [human._id, agent._id] }; + const shared = { _id: id(), name: 'Shared', type: 'standard', createdBy: other._id, members: [human._id, other._id] }; + await Pod.collection.insertMany([pod, shared]); + await Message.collection.insertOne({ podId: pod._id, userId: agent._id, content: 'Independent history' }); + await AgentMemory.collection.insertOne({ agentName: 'independent', instanceId: 'default' }); + await pool.query('INSERT INTO messages VALUES ($1)', [String(pod._id)]); + for (const p of [pod, shared]) { + await pool.query('INSERT INTO pods VALUES ($1,$2)', [String(p._id), p.type]); + for (const m of p.members) await pool.query('INSERT INTO pod_members VALUES ($1,$2)', [String(p._id), String(m)]); + } + const own = await AgentInstallation.create({ agentName: 'independent', podId: pod._id, version: '1.0.0', installedBy: human._id }); + const keep = await AgentInstallation.create({ agentName: 'other-agent', podId: pod._id, version: '1.0.0', installedBy: other._id }); + const key = await AgentCredential.create({ tokenHash: 'owned', kind: 'runtime', ownerUserId: human._id, agentUserId: agent._id }); + const otherKey = await AgentCredential.create({ tokenHash: 'other', kind: 'runtime', ownerUserId: other._id, agentUserId: agent._id }); + const dry = await purgeSyntheticAccounts({ now: NOW, pool }); + expect(await User.findById(human._id)).not.toBeNull(); + expect((await AgentInstallation.findById(own._id)).status).toBe('active'); + expect((await AgentCredential.findById(key._id)).status).toBe('active'); + await purgeSyntheticAccounts({ apply: true, now: NOW, pool }); + const after = await Pod.findById(pod._id); + const pair = pod.members.map(String).sort(); + expect(after?.members.map(String).sort()).toEqual(pair); + const rows = (await pool.query('SELECT user_id FROM pod_members WHERE pod_id=$1', [String(pod._id)])).rows; + expect(rows.map((r) => r.user_id).sort()).toEqual(pair); + expect((await Pod.findById(shared._id)).members.map(String)).toEqual([String(other._id)]); + expect((await pool.query('SELECT user_id FROM pod_members WHERE pod_id=$1', [String(shared._id)])).rows.map((r) => r.user_id)).toEqual([String(other._id)]); + expect(await User.findById(human._id)).toBeNull(); + expect(await User.findById(agent._id)).not.toBeNull(); + expect(await Message.countDocuments({ podId: pod._id, content: 'Independent history' })).toBe(1); + expect(await AgentMemory.countDocuments({ agentName: 'independent' })).toBe(1); + expect((await pool.query('SELECT pod_id FROM messages WHERE pod_id=$1', [String(pod._id)])).rows).toHaveLength(1); + expect((await AgentInstallation.findById(own._id)).status).toBe('paused'); + expect((await AgentInstallation.findById(keep._id)).status).toBe('active'); + expect((await AgentCredential.findById(key._id)).status).toBe('revoked'); + expect((await AgentCredential.findById(otherKey._id)).status).toBe('active'); + expect(dry.counts['agentinstallations.paused']).toBe(1); + expect(dry.accounts[0].preservedDms).toEqual(owned ? [String(pod._id)] : []); + const response = { status: jest.fn().mockReturnThis(), json: jest.fn() }; + await require('../../controllers/podController').getPodById({ params: { id: String(pod._id), type }, userId: String(agent._id) }, response); + expect(response.status).not.toHaveBeenCalled(); + const projected = response.json.mock.calls[0][0]; + expect(projected.members.find((m) => String(m._id) === String(human._id)).username).toBe('Deleted user'); + expect(await require('../../services/dmService').canViewPod(String(agent._id), projected)).toBe(true); + const context = await require('../../services/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('refuses to delete a disposable Scout that another DM depends on', async () => { + const human = await insertUser({ username: 'eng-smoke-journey-1015124709', email: 'journey@ops.commonly.invalid', isBot: false }); + const instance = require('../../services/personaHireService').perUserInstanceId(String(human._id)); + const scout = await insertUser({ username: `scout-${instance}`, isBot: true }); + const other = await insertUser({ username: 'independent', isBot: true }); + const pod = { _id: id(), type: 'agent-dm', createdBy: other._id, members: [scout._id, other._id] }; + await Pod.collection.insertOne(pod); + const report = await purgeSyntheticAccounts({ apply: true, now: NOW, pool }); + expect(report.accounts[0].refused).toMatch(/agent participates in a DM/); + expect(await User.findById(human._id)).not.toBeNull(); + expect(await User.findById(scout._id)).not.toBeNull(); + expect((await Pod.findById(pod._id)).members.map(String)).toEqual(pod.members.map(String)); + }); + + it('deletes a DM wholly inside the disposable footprint', async () => { + const human = await insertUser({ username: 'eng-smoke-journey-1015124709', email: 'journey@ops.commonly.invalid', isBot: false }); + const instance = require('../../services/personaHireService').perUserInstanceId(String(human._id)); + const scout = await insertUser({ username: `scout-${instance}`, isBot: true }); + const pod = { _id: id(), type: 'agent-room', createdBy: human._id, members: [human._id, scout._id] }; + await Pod.collection.insertOne(pod); + await pool.query('INSERT INTO pods VALUES ($1,$2)', [String(pod._id), pod.type]); + await pool.query('INSERT INTO pod_members VALUES ($1,$2)', [String(pod._id), String(human._id)]); + await purgeSyntheticAccounts({ apply: true, now: NOW, pool }); + expect(await Pod.findById(pod._id)).toBeNull(); + // In default mode deletePodCascade intentionally has no configured PG mirror. + if (process.env.INTEGRATION_TEST === 'true') { + expect((await pool.query('SELECT id FROM pods')).rows).toHaveLength(0); + expect((await pool.query('SELECT user_id FROM pod_members')).rows).toHaveLength(0); + } + expect(await User.findById(scout._id)).toBeNull(); + }); + + it('still refuses a synthetic-created DM another human joined', async () => { + const human = await insertUser({ username: 'eng-smoke-journey-1015124709', email: 'journey@ops.commonly.invalid', isBot: false }); + const other = await insertUser({ username: 'real', isBot: false }); + const pod = { _id: id(), type: 'agent-room', createdBy: human._id, members: [human._id, other._id] }; + await Pod.collection.insertOne(pod); + const report = await purgeSyntheticAccounts({ apply: true, now: NOW, pool }); + expect(report.accounts[0].refused).toMatch(/another human/); + expect(await User.findById(human._id)).not.toBeNull(); + expect(await Pod.findById(pod._id)).not.toBeNull(); + }); + + it('refuses to delete a disposable Scout installed by another owner', async () => { + const human = await insertUser({ username: 'eng-smoke-journey-1015124709', email: 'journey@ops.commonly.invalid', isBot: false }); + const other = await insertUser({ username: 'real', isBot: false }); + const instanceId = require('../../services/personaHireService').perUserInstanceId(String(human._id)); + const scout = await insertUser({ username: `scout-${instanceId}`, isBot: true }); + const installation = await AgentInstallation.create({ agentName: 'scout', instanceId, podId: id(), version: '1.0.0', installedBy: other._id }); + const report = await purgeSyntheticAccounts({ apply: true, now: NOW, pool }); + expect(report.accounts[0].refused).toMatch(/Scout is installed by another user/); + expect(await User.findById(human._id)).not.toBeNull(); + expect(await User.findById(scout._id)).not.toBeNull(); + expect((await AgentInstallation.findById(installation._id)).status).toBe('active'); + }); + + it.each([false, true])('refuses a disposable group with an outside-owner install (apply=%s)', async (apply) => { + const human = await insertUser({ username: 'eng-smoke-journey-1015124709', email: 'journey@ops.commonly.invalid', isBot: false }); + const other = await insertUser({ username: 'outside-owner', isBot: false }); + // The installer is intentionally not a member: the foreign-human refusal + // must not accidentally supply the protection being tested here. + const pod = { _id: id(), name: 'Synthetic group', type: 'standard', createdBy: human._id, members: [human._id] }; + await Pod.collection.insertOne(pod); + await pool.query('INSERT INTO pods VALUES ($1,$2)', [String(pod._id), pod.type]); + await pool.query('INSERT INTO pod_members VALUES ($1,$2)', [String(pod._id), String(human._id)]); + await Message.collection.insertOne({ podId: pod._id, userId: human._id, content: 'Retain on refusal' }); + await pool.query('INSERT INTO messages VALUES ($1)', [String(pod._id)]); + const installation = await AgentInstallation.create({ agentName: 'outside-agent', podId: pod._id, version: '1.0.0', installedBy: other._id }); + const credential = await AgentCredential.create({ tokenHash: 'outside-install-key', kind: 'runtime', ownerUserId: other._id }); + const report = await purgeSyntheticAccounts({ apply, now: NOW, pool }); + expect((await AgentInstallation.findById(installation._id))?.status).toBe('active'); + expect(report.accounts[0].refused).toMatch(/disposable pod has an installation owned outside/); + expect(await User.findById(human._id)).not.toBeNull(); + expect(await User.findById(other._id)).not.toBeNull(); + expect(await Pod.findById(pod._id)).not.toBeNull(); + expect(await Message.countDocuments({ podId: pod._id })).toBe(1); + expect((await AgentCredential.findById(credential._id)).status).toBe('active'); + expect((await pool.query('SELECT id FROM pods WHERE id=$1', [String(pod._id)])).rows).toHaveLength(1); + expect((await pool.query('SELECT user_id FROM pod_members WHERE pod_id=$1', [String(pod._id)])).rows).toHaveLength(1); + expect((await pool.query('SELECT pod_id FROM messages WHERE pod_id=$1', [String(pod._id)])).rows).toHaveLength(1); + expect(Object.values(report.counts).every((count) => count === 0)).toBe(true); + }); + +}); diff --git a/backend/__tests__/unit/routes/pods.test.js b/backend/__tests__/unit/routes/pods.test.js index fac3a84fa8..56ce8520c3 100644 --- a/backend/__tests__/unit/routes/pods.test.js +++ b/backend/__tests__/unit/routes/pods.test.js @@ -12,7 +12,10 @@ jest.mock('../../../controllers/podController', () => ({ deletePod: jest.fn((req, res) => res.status(200).end()), })); -jest.mock('../../../middleware/auth', () => (req, res, next) => next()); +jest.mock('../../../middleware/auth', () => jest.fn((req, res, next) => { + if (req.get('authorization')?.startsWith('Bearer invalid-')) return res.status(401).end(); + return next(); +})); const routes = require('../../../routes/pods'); const controllers = require('../../../controllers/podController'); @@ -73,6 +76,53 @@ describe('pods routes', () => { await request(app)[method](url).set('Authorization', `${token}-other`).expect(status); }); + it('shares one lifecycle budget across create, leave and member removal', async () => { + const token = 'Bearer lifecycle-shared'; + const cases = [ + ['post', '/api/pods', 201, 'createPod'], + ['post', '/api/pods/123/leave', 200, 'leavePod'], + ['delete', '/api/pods/123/members/456', 200, 'removeMember'], + ]; + for (const [method, url, status, handler] of cases) { + const before = controllers[handler].mock.calls.length; + for (let i = 0; i < 20; i += 1) { + await request(app)[method](url).set('Authorization', token).expect(status); + } + expect(controllers[handler].mock.calls.length - before).toBe(20); + } + for (const [method, url, , handler] of cases) { + const before = controllers[handler].mock.calls.length; + await request(app)[method](url).set('Authorization', token).expect(429); + expect(controllers[handler].mock.calls.length).toBe(before); + } + }); + + it('bounds rotating invalid credentials before auth while separating client IPs', async () => { + const server = app.listen(0); + try { + const auth = require('../../../middleware/auth'); + const urls = ['/api/pods', '/api/pods/123/leave']; + for (let i = 0; i < 6000; i += 1) { + await request(server).post(urls[i % urls.length]) + .set('cf-connecting-ip', '198.51.100.90') + .set('Authorization', `Bearer invalid-${i}`).expect((res) => { + if (res.status !== 401) throw new Error(`iteration ${i}: ${res.status} ${res.text}`); + }); + } + const before = auth.mock.calls.length; + await request(server).delete('/api/pods/123/members/456') + .set('cf-connecting-ip', '198.51.100.90') + .set('Authorization', 'Bearer invalid-over-budget').expect(429); + expect(auth.mock.calls.length).toBe(before); + await request(server).post('/api/pods') + .set('cf-connecting-ip', '203.0.113.90') + .set('Authorization', 'Bearer invalid-other-client').expect(401); + expect(auth.mock.calls.length).toBe(before + 1); + } finally { + await new Promise((resolve) => server.close(resolve)); + } + }, 60000); + 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/routes/pods.ts b/backend/routes/pods.ts index 8a65fa6c52..c6be759714 100644 --- a/backend/routes/pods.ts +++ b/backend/routes/pods.ts @@ -120,6 +120,17 @@ const podListingRateLimit = rateLimit({ legacyHeaders: false, keyGenerator: podJoinRateLimitKey, }); +// Coarse ingress ceiling also counts rotating invalid credentials before they +// allocate token-store entries or reach authentication. Use the canonical edge +// IP key, not the reverse proxy address. Clients sharing a public IP share this +// broader budget; the 60/min token budget below remains their write isolation. +const podLifecycleIngressRateLimit = rateLimit({ + windowMs: 60_000, + limit: 6000, + standardHeaders: 'draft-7', + legacyHeaders: false, + keyGenerator: cloudflareIpRateLimitKeyGenerator, +}); // 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. @@ -131,7 +142,7 @@ const podLifecycleRateLimit = rateLimit({ keyGenerator: podJoinRateLimitKey, }); router.get('/', podListingRateLimit, auth, getAllPods); -router.post('/', podLifecycleRateLimit, auth, createPod); +router.post('/', podLifecycleIngressRateLimit, podLifecycleRateLimit, auth, createPod); router.post('/announcement', auth, async (req: AuthReq, res: Res) => { try { @@ -416,8 +427,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', podLifecycleRateLimit, auth, leavePod); -router.delete('/:id/members/:memberId', podLifecycleRateLimit, auth, removeMember); +router.post('/:id/leave', podLifecycleIngressRateLimit, podLifecycleRateLimit, auth, leavePod); +router.delete('/:id/members/:memberId', podLifecycleIngressRateLimit, 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 diff --git a/backend/services/syntheticAccountPurgeService.ts b/backend/services/syntheticAccountPurgeService.ts index 444e9a09c4..32bd801461 100644 --- a/backend/services/syntheticAccountPurgeService.ts +++ b/backend/services/syntheticAccountPurgeService.ts @@ -1,3 +1,4 @@ +import { DM_POD_TYPES_GUARD } from './agentIdentityService'; import { withAdminUserMutationLock } from './adminUserMutationService'; /* * Purge the throwaway accounts the stranger journey signs up (TASK-260). @@ -7,12 +8,13 @@ import { withAdminUserMutationLock } from './adminUserMutationService'; * every deploy. Its own cleanup can only uninstall the seat and delete the pod: * there is no self-delete route, and the admin delete removes the Mongo user * row only. Everything else a signup leaves behind would otherwise accumulate - * as fake users. This removes exactly that footprint, and nothing that a real - * person could own: + * as fake users. Cleanup is limited by the footprint and dependency refusals + * below; these are snapshot checks, not a fence against concurrent installs. + * Foreign group dependencies of disposable bots remain tracked in TASK-007: * * per synthetic user H (all three must hold: username pattern, email domain, * not a bot) and older than minAgeMs: - * pods created by H → deletePodCascade + the pod-keyed rows it does not + * disposable pods by H → deletePodCascade + the pod-keyed rows it does not * cover (tasks, agent events/runs, approvals, * attention items, first-message and silence marks, * PG message claims) @@ -32,6 +34,11 @@ import { withAdminUserMutationLock } from './adminUserMutationService'; * - no one else has an installation of that name; * - its bot user, if any, was created at or after H (an account cannot own * an identity older than itself). + * DMs with participants outside this disposable footprint retain their historical + * pair and data. A disposable bot used by another DM or a Scout installed by + * another owner refuses the account purge. Any pod selected for cascade with an + * outside-footprint installation also refuses before cleanup. Shared-pod + * membership still removes H. * Memory is deleted only for the seat's own (name, 'default') instance. Names * are not yet owned by an identity (TASK-270); once they are, the seat test * becomes "the seat's owner is H". Dry run is the default. @@ -47,6 +54,7 @@ const PodMemberFirstMessage = require('../models/PodMemberFirstMessage'); const OnboardingSilenceEpisode = require('../models/OnboardingSilenceEpisode'); const AgentMemory = require('../models/AgentMemory'); const Summary = require('../models/Summary'); +const AgentCredential = require('../models/AgentCredential'); const { AgentRegistry, AgentInstallation } = require('../models/AgentRegistry'); const { perUserInstanceId } = require('./personaHireService'); const { deletePodCascade } = require('./podDeletionService'); @@ -69,6 +77,8 @@ export interface AccountReport { refused?: string; pods: string[]; scoutBot: string | null; + /** Historical DMs retained for a participant outside the disposable footprint. */ + preservedDms: string[]; seats: string[]; /** Seats left alone, and why. */ skippedSeats: { name: string; reason: string }[]; @@ -168,7 +178,11 @@ const purgeSyntheticAccountsUnlocked = async ( const ownIds = new Set([humanId, scoutBot && String(scoutBot._id), ...seatBots.map((b: any) => String(b._id))] .filter(Boolean) as string[]); - const pods = await Pod.find({ createdBy: human._id }).select('_id members').lean(); + const createdPods = await Pod.find({ createdBy: human._id }).select('_id type members').lean(); + const preservedDms = createdPods.filter((pod: any) => DM_POD_TYPES_GUARD.has(String(pod.type)) + && (pod.members || []).some((member: unknown) => !ownIds.has(String(member)))); + const preservedIds = new Set(preservedDms.map((pod: any) => String(pod._id))); + const pods = createdPods.filter((pod: any) => !preservedIds.has(String(pod._id))); const account: AccountReport = { userId: humanId, username: human.username, @@ -176,9 +190,36 @@ const purgeSyntheticAccountsUnlocked = async ( scoutBot: scoutBot ? scoutUsername : null, seats: seatNames, skippedSeats, + preservedDms: preservedDms.map((pod: any) => String(pod._id)), }; report.accounts.push(account); - const foreignIds = pods.flatMap((p: any) => (p.members || []).map(String)).filter((m: string) => !ownIds.has(m)); + // A disposable bot can acquire a life outside this throwaway footprint. + // Refuse rather than deleting an identity another DM still depends on. + if (scoutBot && await AgentInstallation.exists({ + agentName: 'scout', instanceId: perUserInstanceId(humanId), installedBy: { $ne: human._id }, + })) { + account.refused = 'the disposable Scout is installed by another user'; + continue; + } + const disposableBotIds = [scoutBot?._id, ...seatBots.map((bot: any) => bot._id)].filter(Boolean); + const botDms = disposableBotIds.length ? await Pod.find({ + type: { $in: [...DM_POD_TYPES_GUARD] }, members: { $in: disposableBotIds }, + }).select('createdBy members').lean() : []; + if (botDms.some((pod: any) => String(pod.createdBy) !== humanId + || (pod.members || []).some((member: unknown) => !ownIds.has(String(member))))) { + account.refused = 'a disposable agent participates in a DM outside the synthetic footprint'; + continue; + } + // Cascading a disposable pod removes every installation in it. Membership + // alone cannot prove ownership: an outside installer need not be a member. + if (pods.length && await AgentInstallation.exists({ + podId: { $in: pods.map((pod: any) => pod._id) }, installedBy: { $nin: [...ownIds] }, + })) { + account.refused = 'a disposable pod has an installation owned outside the synthetic footprint'; + continue; + } + const foreignIds = createdPods.flatMap((p: any) => (p.members || []).map(String)) + .filter((member: string) => !ownIds.has(member)); const foreignHuman = foreignIds.length ? await User.findOne({ _id: { $in: foreignIds }, isBot: { $ne: true } }).select('_id').lean() : null; @@ -228,13 +269,31 @@ const purgeSyntheticAccountsUnlocked = async ( await remove('onboardingsilenceepisodes', OnboardingSilenceEpisode, { userId: human._id }); await remove('podmemberfirstmessages', PodMemberFirstMessage, { userId: human._id }); if (apply) { + const credentials = await AgentCredential.find({ ownerUserId: human._id }).select('_id').lean(); beginWrite(); - const res = await Pod.updateMany({ members: human._id }, { $pull: { members: human._id } }); + await AgentCredential.revokeCascade(credentials.map((credential: any) => credential._id)); + const paused = await AgentInstallation.updateMany( + { installedBy: human._id, status: 'active' }, { $set: { status: 'paused' } }, + ); + tally('agentinstallations.paused', paused.modifiedCount); + const res = await Pod.updateMany( + { members: human._id, type: { $nin: [...DM_POD_TYPES_GUARD] } }, + { $pull: { members: human._id } }, + ); tally('pod.members', res.modifiedCount); } else { - tally('pod.members', await Pod.countDocuments({ members: human._id, createdBy: { $ne: human._id } })); + tally('agentinstallations.paused', await AgentInstallation.countDocuments({ + installedBy: human._id, status: 'active', podId: { $nin: pods.map((pod: any) => pod._id) }, + })); + tally('pod.members', await Pod.countDocuments({ + members: human._id, createdBy: { $ne: human._id }, type: { $nin: [...DM_POD_TYPES_GUARD] }, + })); } - await pgRemove('pg.pod_members', 'DELETE FROM pod_members WHERE user_id = $1', [humanId]); + const dmTypes = [...DM_POD_TYPES_GUARD]; + const dmParams = dmTypes.map((_type, index) => `$${index + 2}`).join(', '); + await pgRemove('pg.pod_members', + `DELETE FROM pod_members WHERE user_id = $1 + AND pod_id NOT IN (SELECT id FROM pods WHERE type IN (${dmParams}))`, [humanId, ...dmTypes]); await pgRemove('pg.users', 'DELETE FROM users WHERE _id = $1', [humanId]); await remove('users', User, { _id: human._id }); } diff --git a/docs/runbooks/alerting.md b/docs/runbooks/alerting.md index 27666a251e..d84a52ce90 100644 --- a/docs/runbooks/alerting.md +++ b/docs/runbooks/alerting.md @@ -300,6 +300,39 @@ as uid 65534 with a read-only root filesystem and no capabilities. The `/register` limit is 10 per hour per client IP, and the cluster egress IP is not shared with anyone's laptop. +### Synthetic purge and historical DMs (TASK-005) + +The purge retains both historical member IDs in Mongo and PostgreSQL for +`agent-room` and `agent-dm`. Ordinary shared pods lose the synthetic member. +A synthetic-created DM with a participant outside the disposable footprint is +reported in `preservedDms` and retained with its messages and installations. +Only active installations owned by the purged human pause; their recorded credential +ledger roots and descendants are revoked. Unrelated other-owner keys and installations, +and independent agent identities and memory, remain. The pod/member projection +and runtime context retain an opaque missing-user ID with a `Deleted user` label. +This uses the same read projection as account deletion (#2721). + +A DM wholly within the disposable footprint can still be cascaded. If a disposable +Scout or seat bot is used in a DM outside that footprint, or the Scout is +installed by another owner, the whole account is +refused rather than deleting that identity. Any pod selected for cascade with an +installation owned outside the disposable footprint also refuses the whole account before cleanup, even when that installer +is not a member. The existing refusal for a synthetic-created pod another human +joined remains. Inspect `refused` and +`preservedDms` in the dry-run report; do not force a refused candidate through. +The global administrator mutation lock and uncertain-write recovery rules still +apply ([runbook](admin-user-mutation-lock.md)). This change does not add a durable +late-producer fence to synthetic purge or implement receipt retirement. + +The overlapping pod lifecycle handlers (create, leave and member removal) share +60 requests/minute per presented token, preceded by a coarse 6,000/minute client-IP +budget. The latter bounds rotating invalid credentials before authentication and +token-store allocation. Both use process-local stores; this is not a cluster-wide +quota or a global bound across arbitrarily many IPs. Clients sharing a public IP +share the coarse budget. Client-IP attribution inherits the documented edge/tunnel +assumptions in `middleware/ipRateLimit.ts`; use that deployment's configured client +identity rather than treating the reverse proxy address as the external caller. + ### User spend "One user's agent spend passed the daily line" (WARNING) fires when a user's