diff --git a/backend/__tests__/service/machineRevocation.test.js b/backend/__tests__/service/machineRevocation.test.js new file mode 100644 index 0000000000..509a451612 --- /dev/null +++ b/backend/__tests__/service/machineRevocation.test.js @@ -0,0 +1,293 @@ +const request = require('supertest'); +const express = require('express'); +const mongoose = require('mongoose'); +const { setupMongoDb, closeMongoDb, clearMongoDb, generateTestToken } = require('../utils/testUtils'); +const { hash } = require('../../utils/secret'); +const { hashDeviceCredential } = require('../../services/deviceAuthorizationService'); +const User = require('../../models/User'); +const Machine = require('../../models/Machine'); +const AgentCredential = require('../../models/AgentCredential'); +const { AgentInstallation } = require('../../models/AgentRegistry'); + +const app = express(); +app.use(express.json()); +app.use('/api/auth', require('../../routes/auth')); +app.use('/api/machines', require('../../routes/machines')); +app.use('/api/credentials', require('../../routes/credentials')); +const agentRuntimeAuth = require('../../middleware/agentRuntimeAuth').default; + +const agentCall = async (token) => { + const req = { header: (name) => (name === 'Authorization' ? `Bearer ${token}` : undefined) }; + const res = { statusCode: 200 }; + res.status = (code) => { res.statusCode = code; return res; }; + res.json = () => res; + let nexted = false; + await agentRuntimeAuth(req, res, () => { nexted = true; }); + return nexted ? 204 : res.statusCode; +}; + +const owner = async () => User.create({ + username: `owner-${new mongoose.Types.ObjectId().toString().slice(0, 8)}`, + email: `${new mongoose.Types.ObjectId()}@example.test`, + password: 'Password123!', + verified: true, +}); + +const addDevice = async (user, suffix) => { + const token = `cm_${suffix.repeat(64)}`; + user.deviceTokens.push({ + tokenHash: hashDeviceCredential(token), label: `laptop-${suffix} · commonly-cli`, createdAt: new Date(), + }); + await user.save(); + return { token, id: String(user.deviceTokens[user.deviceTokens.length - 1]._id) }; +}; + +const register = async (token, name) => request(app) + .post('/api/machines') + .set('Authorization', `Bearer ${token}`) + .send({ name }) + .expect(201); + +const daemonCall = (token, machineDbId) => request(app) + .post(`/api/machines/${machineDbId}/heartbeat`) + .set('Authorization', `Bearer ${token}`) + .send({ agents: [] }); + +const addRuntimeChild = async (user, machine, daemonToken, suffix) => { + const bot = await User.create({ + username: `bot-${suffix}`, email: `bot-${suffix}@agents.commonly.local`, + password: 'Password123!', isBot: true, + botMetadata: { agentName: `bot-${suffix}`, instanceId: 'default', machineId: machine.machineId }, + }); + await AgentInstallation.create({ + agentName: `bot-${suffix}`, instanceId: 'default', podId: new mongoose.Types.ObjectId(), + version: '1.0.0', installedBy: user._id, + }); + const token = `cm_agent_${suffix.repeat(32)}`; + bot.agentRuntimeTokens.push({ tokenHash: hash(token), label: 'seat', createdAt: new Date() }); + await bot.save(); + const daemon = await AgentCredential.findOne({ tokenHash: hash(daemonToken) }); + const child = await AgentCredential.create({ + tokenHash: hash(token), kind: 'runtime', ownerUserId: user._id, + agentUserId: bot._id, machineId: machine.machineId, parentId: daemon._id, + }); + return { token, child }; +}; + +beforeAll(async () => { + process.env.JWT_SECRET = 'machine-revocation-test-secret'; + await setupMongoDb(); +}); +afterEach(async () => clearMongoDb()); +afterAll(async () => closeMongoDb()); + +describe('machine credential revocation', () => { + it('revoking a CLI device removes its machine and rejects daemon and minted child on the next call', async () => { + const user = await owner(); + const device = await addDevice(user, 'a'); + const otherDevice = await addDevice(user, 'b'); + const first = (await register(device.token, 'first')).body; + const other = (await register(otherDevice.token, 'other')).body; + const child = await addRuntimeChild(user, first.machine, first.daemonToken, 'first'); + + await daemonCall(first.daemonToken, first.machine.id).expect(200); + expect(await agentCall(child.token)).toBe(204); + + await request(app).delete(`/api/auth/devices/${device.id}`) + .set('Authorization', `Bearer ${generateTestToken(user._id)}`).expect(200); + + expect(await Machine.findById(first.machine.id)).toBeNull(); + expect(await AgentCredential.findById(child.child._id)).toMatchObject({ status: 'revoked' }); + await daemonCall(first.daemonToken, first.machine.id).expect(401); + expect(await agentCall(child.token)).toBe(401); + await daemonCall(other.daemonToken, other.machine.id).expect(200); + }); + + it('a failed revoke cleanup still denies daemon and runtime credentials immediately', async () => { + const user = await owner(); + const device = await addDevice(user, 'q'); + const first = (await register(device.token, 'cleanup-fails')).body; + const child = await addRuntimeChild(user, first.machine, first.daemonToken, 'cleanup'); + const revokeSpy = jest.spyOn(AgentCredential, 'revokeCascade').mockRejectedValueOnce(new Error('cleanup failed')); + try { + await request(app).delete(`/api/auth/devices/${device.id}`) + .set('Authorization', `Bearer ${generateTestToken(user._id)}`).expect(500); + } finally { + revokeSpy.mockRestore(); + } + + expect(await Machine.findById(first.machine.id)).toBeTruthy(); + expect(await AgentCredential.findById(child.child._id)).toMatchObject({ status: 'active' }); + await daemonCall(first.daemonToken, first.machine.id).expect(401); + expect(await agentCall(child.token)).toBe(401); + // A retry completes the cleanup after the durable revoke. + await request(app).delete(`/api/auth/devices/${device.id}`) + .set('Authorization', `Bearer ${generateTestToken(user._id)}`).expect(200); + expect(await Machine.findById(first.machine.id)).toBeNull(); + }); + + it('removing a machine revokes every daemon and minted child, including a standalone machine credential', async () => { + const user = await owner(); + const device = await addDevice(user, 'c'); + const first = (await register(device.token, 'first')).body; + const child = await addRuntimeChild(user, first.machine, first.daemonToken, 'removed'); + const extraToken = `cm_daemon_${'e'.repeat(32)}`; + await AgentCredential.create({ + tokenHash: hash(extraToken), kind: 'daemon', ownerUserId: user._id, + machineId: first.machine.machineId, scopes: ['machine:heartbeat'], + }); + + await request(app).delete(`/api/machines/${first.machine.id}`) + .set('Authorization', `Bearer ${generateTestToken(user._id)}`).expect(200); + + const remaining = await AgentCredential.find({ machineId: first.machine.machineId }); + expect(remaining).toHaveLength(3); + expect(remaining.every((credential) => credential.status === 'revoked')).toBe(true); + await daemonCall(first.daemonToken, first.machine.id).expect(401); + await daemonCall(extraToken, first.machine.id).expect(401); + expect(await agentCall(child.token)).toBe(401); + + // Even if an old daemon row escaped the revocation sweep, its missing + // machine still vetoes the next request. + await AgentCredential.updateOne( + { tokenHash: hash(first.daemonToken) }, + { $set: { status: 'active', revokedAt: null } }, + ); + await daemonCall(first.daemonToken, first.machine.id).expect(401); + expect(await agentCall(child.token)).toBe(401); + }); + + it('revoking a daemon credential in the owner UI rejects it and its minted child on the next call', async () => { + const user = await owner(); + const device = await addDevice(user, 'f'); + const first = (await register(device.token, 'credential-revoke')).body; + const child = await addRuntimeChild(user, first.machine, first.daemonToken, 'credential'); + const daemon = await AgentCredential.findOne({ tokenHash: hash(first.daemonToken) }); + + await request(app).delete(`/api/credentials/${daemon._id}`) + .set('Authorization', `Bearer ${generateTestToken(user._id)}`).expect(200); + + expect(await AgentCredential.findById(child.child._id)).toMatchObject({ status: 'revoked' }); + await daemonCall(first.daemonToken, first.machine.id).expect(401); + expect(await agentCall(child.token)).toBe(401); + }); + + it('retires pre-binding machines conservatively when their owner revokes a device', async () => { + const user = await owner(); + const device = await addDevice(user, 'd'); + const first = (await register(device.token, 'older')).body; + await Machine.updateOne({ _id: first.machine.id }, { $unset: { registeredByDeviceTokenHash: 1 } }); + + await request(app).delete(`/api/auth/devices/${device.id}`) + .set('Authorization', `Bearer ${generateTestToken(user._id)}`).expect(200); + + expect(await Machine.findById(first.machine.id)).toBeNull(); + await daemonCall(first.daemonToken, first.machine.id).expect(401); + }); + + it('keeps a new browser-registered machine separate from an unrelated revoked device', async () => { + const user = await owner(); + const device = await addDevice(user, 'g'); + const browserToken = generateTestToken(user._id); + const machine = (await register(browserToken, 'browser-registered')).body; + + expect((await Machine.findById(machine.machine.id)).registeredByDeviceTokenHash).toBeNull(); + await request(app).delete(`/api/auth/devices/${device.id}`) + .set('Authorization', `Bearer ${browserToken}`).expect(200); + + expect(await Machine.findById(machine.machine.id)).toBeTruthy(); + await daemonCall(machine.daemonToken, machine.machine.id).expect(200); + }); + + it('a device revoke racing registration cannot leave a newly minted daemon alive', async () => { + const user = await owner(); + const device = await addDevice(user, 'h'); + let continueCheck; + let checkStarted; + const started = new Promise((resolve) => { checkStarted = resolve; }); + const resume = new Promise((resolve) => { continueCheck = resolve; }); + const originalExists = User.exists.bind(User); + const existsSpy = jest.spyOn(User, 'exists').mockImplementationOnce(async (...args) => { + checkStarted(); + await resume; + return originalExists(...args); + }); + try { + const registration = request(app).post('/api/machines') + .set('Authorization', `Bearer ${device.token}`).send({ name: 'racing' }); + const pending = registration.then((response) => response); + await started; + await request(app).delete(`/api/auth/devices/${device.id}`) + .set('Authorization', `Bearer ${generateTestToken(user._id)}`).expect(200); + continueCheck(); + expect((await pending).status).toBe(401); + expect(await Machine.countDocuments({ ownerUserId: user._id })).toBe(0); + expect(await AgentCredential.countDocuments({ ownerUserId: user._id, status: 'active' })).toBe(0); + } finally { + existsSpy.mockRestore(); + continueCheck(); + } + }); + + it('a machine deletion racing registration refuses the new bearer', async () => { + const user = await owner(); + const device = await addDevice(user, 'i'); + let continueCheck; + let checkStarted; + const started = new Promise((resolve) => { checkStarted = resolve; }); + const resume = new Promise((resolve) => { continueCheck = resolve; }); + const originalExists = Machine.exists.bind(Machine); + const existsSpy = jest.spyOn(Machine, 'exists').mockImplementationOnce(async (...args) => { + checkStarted(); + await resume; + return originalExists(...args); + }); + try { + const registration = request(app).post('/api/machines') + .set('Authorization', `Bearer ${device.token}`).send({ name: 'racing-machine' }); + const pending = registration.then((response) => response); + await started; + const machine = await Machine.findOne({ ownerUserId: user._id, name: 'racing-machine' }); + await request(app).delete(`/api/machines/${machine._id}`) + .set('Authorization', `Bearer ${generateTestToken(user._id)}`).expect(200); + continueCheck(); + expect((await pending).status).toBe(409); + expect(await AgentCredential.countDocuments({ machineId: machine.machineId, status: 'active' })).toBe(0); + } finally { + existsSpy.mockRestore(); + continueCheck(); + } + }); + + it('removal catches a credential inserted after its first scan', async () => { + const user = await owner(); + const device = await addDevice(user, 'j'); + const first = (await register(device.token, 'late-mint')).body; + let continueDelete; + let deleteStarted; + const started = new Promise((resolve) => { deleteStarted = resolve; }); + const resume = new Promise((resolve) => { continueDelete = resolve; }); + const originalDelete = Machine.deleteOne.bind(Machine); + const deleteSpy = jest.spyOn(Machine, 'deleteOne').mockImplementationOnce(async (...args) => { + deleteStarted(); + await resume; + return originalDelete(...args); + }); + try { + const deletion = request(app).delete(`/api/machines/${first.machine.id}`) + .set('Authorization', `Bearer ${generateTestToken(user._id)}`); + const pending = deletion.then((response) => response); + await started; + const late = await AgentCredential.create({ + tokenHash: hash(`cm_daemon_${'k'.repeat(32)}`), kind: 'daemon', ownerUserId: user._id, + machineId: first.machine.machineId, scopes: ['machine:heartbeat'], + }); + continueDelete(); + expect((await pending).status).toBe(200); + expect(await AgentCredential.findById(late._id)).toMatchObject({ status: 'revoked' }); + } finally { + deleteSpy.mockRestore(); + continueDelete(); + } + }); +}); diff --git a/backend/__tests__/unit/middleware/daemonAuth.test.js b/backend/__tests__/unit/middleware/daemonAuth.test.js index e28e01be54..0b09051e10 100644 --- a/backend/__tests__/unit/middleware/daemonAuth.test.js +++ b/backend/__tests__/unit/middleware/daemonAuth.test.js @@ -4,6 +4,7 @@ const { hash } = require('../../../utils/secret'); let mongod; let AgentCredential; +let Machine; let daemonAuth; let agentRuntimeAuth; @@ -11,12 +12,14 @@ beforeAll(async () => { mongod = await MongoMemoryServer.create(); await mongoose.connect(mongod.getUri()); AgentCredential = require('../../../models/AgentCredential'); + Machine = require('../../../models/Machine'); daemonAuth = require('../../../middleware/daemonAuth'); agentRuntimeAuth = require('../../../middleware/agentRuntimeAuth').default; }); afterEach(async () => { await AgentCredential.deleteMany({}); + await Machine.deleteMany({}); }); afterAll(async () => { @@ -35,6 +38,7 @@ describe('daemonAuth', () => { it('authenticates only a scoped daemon credential and never assigns an agent identity', async () => { const token = `cm_daemon_${'a'.repeat(32)}`; const ownerUserId = new mongoose.Types.ObjectId(); + await Machine.create({ ownerUserId, machineId: 'machine-1', name: 'Test machine' }); await AgentCredential.create({ tokenHash: hash(token), kind: 'daemon', @@ -72,10 +76,12 @@ describe('daemonAuth', () => { it('requires the route scope before exposing a machine identity', async () => { const token = `cm_daemon_${'b'.repeat(32)}`; + const ownerUserId = new mongoose.Types.ObjectId(); + await Machine.create({ ownerUserId, machineId: 'machine-2', name: 'Test machine' }); await AgentCredential.create({ tokenHash: hash(token), kind: 'daemon', - ownerUserId: new mongoose.Types.ObjectId(), + ownerUserId, machineId: 'machine-2', scopes: ['machine:heartbeat'], }); diff --git a/backend/__tests__/unit/routes/agentBinding.adoption.test.js b/backend/__tests__/unit/routes/agentBinding.adoption.test.js index a72de34315..24d3fba0ba 100644 --- a/backend/__tests__/unit/routes/agentBinding.adoption.test.js +++ b/backend/__tests__/unit/routes/agentBinding.adoption.test.js @@ -15,7 +15,7 @@ jest.mock('../../../middleware/auth', () => { const { hash } = require('../../../utils/secret'); -let mongod; let app; let AgentCredential; let User; let AgentInstallation; +let mongod; let app; let AgentCredential; let User; let AgentInstallation; let Machine; const DAEMON_A = `cm_daemon_${'a'.repeat(32)}`; const DAEMON_B = `cm_daemon_${'b'.repeat(32)}`; @@ -24,6 +24,7 @@ beforeAll(async () => { await mongoose.connect(mongod.getUri()); AgentCredential = require('../../../models/AgentCredential'); User = require('../../../models/User'); + Machine = require('../../../models/Machine'); AgentInstallation = require('../../../models/AgentRegistry').AgentInstallation; app = express(); app.use(express.json()); @@ -35,7 +36,7 @@ afterAll(async () => { await mongoose.disconnect(); await mongod.stop(); }); let owner; let bot; beforeEach(async () => { await Promise.all([ - User.deleteMany({}), AgentCredential.deleteMany({}), AgentInstallation.deleteMany({}), + User.deleteMany({}), AgentCredential.deleteMany({}), AgentInstallation.deleteMany({}), Machine.deleteMany({}), ]); owner = await User.create({ username: `o${Date.now() % 1e6}`, email: `o${Date.now()}@x.com`, password: 'x'.repeat(12) }); global.__CALLER_ID = String(owner._id); @@ -48,6 +49,7 @@ beforeEach(async () => { version: '1.0.0', status: 'active', installedBy: owner._id, }); for (const [tok, mid] of [[DAEMON_A, 'machine-a'], [DAEMON_B, 'machine-b']]) { + await Machine.create({ ownerUserId: owner._id, machineId: mid, name: mid }); await AgentCredential.create({ tokenHash: hash(tok), kind: 'daemon', ownerUserId: owner._id, machineId: mid, scopes: ['machine:heartbeat', 'agents:adopt'] }); } }); @@ -84,6 +86,7 @@ describe('adoption CAS', () => { it('refuses adoption of an agent the daemon owner does not own', async () => { const stranger = await User.create({ username: 'str', email: 's@x.com', password: 'x'.repeat(12) }); const tok = `cm_daemon_${'s'.repeat(32)}`; + await Machine.create({ ownerUserId: stranger._id, machineId: 'machine-s', name: 'Stranger machine' }); await AgentCredential.create({ tokenHash: hash(tok), kind: 'daemon', ownerUserId: stranger._id, machineId: 'machine-s', scopes: ['machine:heartbeat', 'agents:adopt'] }); const res = await adopt(tok); expect(res.status).toBe(403); @@ -92,6 +95,7 @@ describe('adoption CAS', () => { it('a heartbeat-only credential cannot adopt — scopes are enforced, not decorative', async () => { const tok = `cm_daemon_${'h'.repeat(32)}`; + await Machine.create({ ownerUserId: owner._id, machineId: 'machine-h', name: 'Heartbeat machine' }); await AgentCredential.create({ tokenHash: hash(tok), kind: 'daemon', ownerUserId: owner._id, machineId: 'machine-h', scopes: ['machine:heartbeat'], diff --git a/backend/__tests__/unit/routes/agentBinding.phase2.test.js b/backend/__tests__/unit/routes/agentBinding.phase2.test.js index 6b257568e2..ab9bd00e91 100644 --- a/backend/__tests__/unit/routes/agentBinding.phase2.test.js +++ b/backend/__tests__/unit/routes/agentBinding.phase2.test.js @@ -33,6 +33,7 @@ beforeAll(async () => { app = express(); app.use(express.json()); app.use('/api/agent-binding', require('../../../routes/agentBinding')); + app.use('/api/machines', require('../../../routes/machines')); }); afterAll(async () => { await mongoose.disconnect(); await mongod.stop(); }); @@ -540,6 +541,40 @@ describe('runtime-token mint', () => { })); }); + it('refuses a mint paused across machine removal without leaving a live bearer', async () => { + await adopt(DAEMON_A); + let resumeLookup; + let lookupStarted; + const started = new Promise((resolve) => { lookupStarted = resolve; }); + const resume = new Promise((resolve) => { resumeLookup = resolve; }); + const originalFindOne = AgentCredential.findOne.bind(AgentCredential); + const spy = jest.spyOn(AgentCredential, 'findOne').mockImplementation((filter, ...args) => { + const query = originalFindOne(filter, ...args); + if (filter?.kind === 'daemon' && filter?.status === 'active' && filter?.machineId === 'machine-a') { + const originalLean = query.lean.bind(query); + query.lean = async (...leanArgs) => { + lookupStarted(); + await resume; + return originalLean(...leanArgs); + }; + } + return query; + }); + try { + const pending = mint(DAEMON_A).then((response) => response); + await started; + const machine = await Machine.findOne({ machineId: 'machine-a' }); + await request(app).delete(`/api/machines/${machine._id}`).expect(200); + resumeLookup(); + expect((await pending).status).toBe(401); + expect(await AgentCredential.countDocuments({ kind: 'runtime', machineId: 'machine-a', status: 'active' })).toBe(0); + expect((await User.findById(bot._id)).agentRuntimeTokens).toHaveLength(0); + } finally { + spy.mockRestore(); + resumeLookup(); + } + }); + // Rhea on #2224, Wren for plain native: a daemon cannot mint a token for an // agent Commonly runs in-process, whatever the binding says. it.each([ diff --git a/backend/__tests__/unit/routes/agentsRuntime.installations.test.js b/backend/__tests__/unit/routes/agentsRuntime.installations.test.js index 0edf074190..cd1629af51 100644 --- a/backend/__tests__/unit/routes/agentsRuntime.installations.test.js +++ b/backend/__tests__/unit/routes/agentsRuntime.installations.test.js @@ -51,10 +51,10 @@ jest.mock('../../../services/objectStore', () => ({ })); jest.mock('../../../models/integrationPublicConfig', () => ({ toPublicIntegrationConfig: jest.fn() })); jest.mock('../../../routes/registry/helpers', () => ({ isGlobalAdminUser: jest.fn() })); -jest.mock('../../../middleware/agentRateLimit', () => ({ agentRateLimitKeyGenerator: jest.fn() })); -jest.mock('../../../middleware/ipRateLimit', () => ({ cloudflareIpRateLimitKeyGenerator: jest.fn() })); +jest.mock('../../../middleware/agentRateLimit', () => ({ agentRateLimitKeyGenerator: jest.fn(() => 'agent-test') })); +jest.mock('../../../middleware/ipRateLimit', () => ({ cloudflareIpRateLimitKeyGenerator: jest.fn(() => 'ip-test') })); jest.mock('../../../utils/discordBotToken', () => ({ resolveDiscordBotToken: jest.fn() })); -jest.mock('../../../middleware/rateLimitObserver', () => ({ rateLimitObserver: jest.fn() })); +jest.mock('../../../middleware/rateLimitObserver', () => ({ rateLimitObserver: jest.fn((_req, _res, next) => next()) })); const express = require('express'); const request = require('supertest'); diff --git a/backend/__tests__/unit/routes/routeRateLimitGuard.baseline.json b/backend/__tests__/unit/routes/routeRateLimitGuard.baseline.json index 9c722144d8..829c09e8f5 100644 --- a/backend/__tests__/unit/routes/routeRateLimitGuard.baseline.json +++ b/backend/__tests__/unit/routes/routeRateLimitGuard.baseline.json @@ -24,29 +24,14 @@ "routes/agentEnsemble.ts PATCH /:podId/ensemble/config [unlimited]", "routes/agentEnsemble.ts POST /:podId/ensemble/complete [unlimited]", "routes/agentEnsemble.ts POST /:podId/ensemble/pause [unlimited]", - "routes/agentEnsemble.ts POST /:podId/ensemble/response [unlimited]", "routes/agentEnsemble.ts POST /:podId/ensemble/resume [unlimited]", "routes/agentEnsemble.ts POST /:podId/ensemble/start [unlimited]", "routes/agentsRuntime.ts GET /bot/pods/:podId/context [unlimited]", "routes/agentsRuntime.ts GET /bot/pods/:podId/messages [unlimited]", - "routes/agentsRuntime.ts GET /installations [unlimited]", - "routes/agentsRuntime.ts GET /memory [unlimited]", - "routes/agentsRuntime.ts GET /pods/:podId/context [unlimited]", - "routes/agentsRuntime.ts GET /pods/:podId/integrations [unlimited]", - "routes/agentsRuntime.ts GET /pods/:podId/integrations/:integrationId/messages [unlimited]", - "routes/agentsRuntime.ts GET /pods/:podId/messages [unlimited]", - "routes/agentsRuntime.ts GET /pods/:podId/posts [unlimited]", - "routes/agentsRuntime.ts GET /pods/:podId/social-policy [unlimited]", "routes/agentsRuntime.ts POST /bot/events/:id/ack [unlimited]", "routes/agentsRuntime.ts POST /bot/pods/:podId/messages [unlimited]", "routes/agentsRuntime.ts POST /bot/threads/:threadId/comments [unlimited]", "routes/agentsRuntime.ts POST /dm [unlimited]", - "routes/agentsRuntime.ts POST /events/:id/ack [unlimited]", - "routes/agentsRuntime.ts POST /pods/:podId/integrations/:integrationId/publish [unlimited]", - "routes/agentsRuntime.ts POST /pods/:podId/summaries [unlimited]", - "routes/agentsRuntime.ts POST /pods/:podId/uploads [unlimited]", - "routes/agentsRuntime.ts POST /posts [unlimited]", - "routes/agentsRuntime.ts POST /threads/:threadId/comments [unlimited]", "routes/apps.ts DELETE /installations/:id [unlimited]", "routes/apps.ts DELETE /pods/:podId/apps/:installationId [unlimited]", "routes/apps.ts GET / [unlimited]", @@ -93,11 +78,7 @@ "routes/federation.ts POST /links/:linkId/revoke [unlimited]", "routes/federation.ts POST /query [unlimited]", "routes/federation.ts POST /search [unlimited]", - "routes/github.ts GET /issues [unlimited]", "routes/github.ts GET /status [unlimited]", - "routes/github.ts POST /issues [unlimited]", - "routes/github.ts POST /issues/:number/close [unlimited]", - "routes/github.ts POST /issues/:number/comment [unlimited]", "routes/health.ts GET / [unlimited]", "routes/health.ts GET /clawdbot [unlimited]", "routes/health.ts GET /db [unlimited]", @@ -188,7 +169,6 @@ "routes/skills.ts PATCH /gateway-credentials [unlimited]", "routes/skills.ts POST /:skillId/rating [unlimited]", "routes/skills.ts POST /import [unlimited]", - "routes/tasksApi.ts GET /:podId [unlimited]", "routes/test-bot.ts POST /discord-summary [unlimited]", "routes/uploads.ts POST / [unlimited]", "routes/users.ts DELETE /:id/follow [unlimited]", diff --git a/backend/__tests__/unit/services/agentCredential.substrate.test.js b/backend/__tests__/unit/services/agentCredential.substrate.test.js index 8cf05dacd0..e4d051ad8d 100644 --- a/backend/__tests__/unit/services/agentCredential.substrate.test.js +++ b/backend/__tests__/unit/services/agentCredential.substrate.test.js @@ -24,6 +24,7 @@ const { hash } = require('../../../utils/secret'); let mongod; let AgentCredential; let User; +let Machine; let agentRuntimeAuth; beforeAll(async () => { @@ -31,6 +32,7 @@ beforeAll(async () => { await mongoose.connect(mongod.getUri()); AgentCredential = require('../../../models/AgentCredential'); User = require('../../../models/User'); + Machine = require('../../../models/Machine'); agentRuntimeAuth = require('../../../middleware/agentRuntimeAuth').default; const { AgentInstallation } = require('../../../models/AgentRegistry'); await AgentInstallation.create({ @@ -149,12 +151,14 @@ describe('per-spawn child credentials (TASK-094)', () => { const makeSeat = async () => { const raw = `cm_agent_${Math.random().toString(36).slice(2)}${'s'.repeat(24)}`; const bot = await makeBot(raw); + const machineId = `machine-${new mongoose.Types.ObjectId()}`; + await Machine.create({ ownerUserId: bot._id, machineId, name: 'Test seat machine' }); const seat = await AgentCredential.create({ tokenHash: hash(raw), kind: 'runtime', ownerUserId: bot._id, agentUserId: bot._id, - machineId: 'machine-seat', + machineId, label: 'Runtime token', scopes: [], }); diff --git a/backend/middleware/agentRuntimeAuth.ts b/backend/middleware/agentRuntimeAuth.ts index 8a303ff96f..da35345345 100644 --- a/backend/middleware/agentRuntimeAuth.ts +++ b/backend/middleware/agentRuntimeAuth.ts @@ -9,6 +9,7 @@ import Pod from '../models/Pod'; import AgentCredential from '../models/AgentCredential'; import { markByoConnectionAttempt } from '../services/onboardingMarks'; import { isSpawnCredential } from '../services/spawnCredentialService'; +import { machineHasLiveAuthorization } from '../services/machineService'; // eslint-disable-next-line global-require const { hash } = require('../utils/secret') as { hash: (value: string) => string }; @@ -114,6 +115,11 @@ export default async function agentRuntimeAuth(req: Request, res: Response, next return res.status(401).json({ message: 'Issuing credential revoked' }); } } + if (credential.machineId && !await machineHasLiveAuthorization( + credential.machineId, credential.ownerUserId, + )) { + return res.status(401).json({ message: 'Machine access revoked' }); + } AgentCredential.updateOne({ _id: credential._id }, { $set: { lastUsedAt: new Date() } }) .catch((err: Error) => console.warn('Failed to update credential usage:', err.message)); } diff --git a/backend/middleware/auth.ts b/backend/middleware/auth.ts index ebc1c51333..b43e6468d5 100644 --- a/backend/middleware/auth.ts +++ b/backend/middleware/auth.ts @@ -83,6 +83,7 @@ export default async function auth(req: Request, res: Response, next: NextFuncti role: user.role, }; req.authType = isDeviceToken ? 'deviceToken' : 'apiToken'; + if (isDeviceToken) req.deviceTokenHash = deviceTokenHash; req.apiTokenScopes = user.apiTokenScopes || []; req.apiTokenCreatedAt = user.apiTokenCreatedAt || null; if (isDeviceToken) { diff --git a/backend/middleware/daemonAuth.ts b/backend/middleware/daemonAuth.ts index 866a18f078..eddcbd6e34 100644 --- a/backend/middleware/daemonAuth.ts +++ b/backend/middleware/daemonAuth.ts @@ -4,6 +4,7 @@ // (Vera's scope ruling on #1312/S1). Sets req.machine only. import { Request, Response, NextFunction } from 'express'; import AgentCredential from '../models/AgentCredential'; +import { machineHasLiveAuthorization } from '../services/machineService'; // eslint-disable-next-line @typescript-eslint/no-require-imports const { hash } = require('../utils/secret') as { hash: (value: string) => string }; @@ -43,6 +44,10 @@ export default function daemonAuth(requiredScope?: string) { if (credential.expiresAt && credential.expiresAt < new Date()) { return res.status(401).json({ message: 'Daemon token expired' }); } + const authorized = await machineHasLiveAuthorization( + String(credential.machineId || ''), credential.ownerUserId, + ); + if (!authorized) return res.status(401).json({ message: 'Machine access revoked' }); if (requiredScope && !(credential.scopes || []).includes(requiredScope)) { return res.status(403).json({ message: `Daemon token lacks scope ${requiredScope}` }); } diff --git a/backend/models/AgentCredential.ts b/backend/models/AgentCredential.ts index 2f92c0ba6c..7eaba5271d 100644 --- a/backend/models/AgentCredential.ts +++ b/backend/models/AgentCredential.ts @@ -66,10 +66,10 @@ AgentCredentialSchema.index({ machineId: 1, kind: 1, status: 1 }); // today (daemon → runtime), but written as a walk so a deeper chain can // never orphan a live child. AgentCredentialSchema.statics.revokeCascade = async function revokeCascade( - credentialId: Types.ObjectId | string, + credentialId: Types.ObjectId | string | (Types.ObjectId | string)[], ): Promise { const now = new Date(); - let frontier: (Types.ObjectId | string)[] = [credentialId]; + let frontier: (Types.ObjectId | string)[] = Array.isArray(credentialId) ? credentialId : [credentialId]; let revoked = 0; while (frontier.length) { const res = await this.updateMany( @@ -84,7 +84,7 @@ AgentCredentialSchema.statics.revokeCascade = async function revokeCascade( }; export interface AgentCredentialModel extends mongoose.Model { - revokeCascade(credentialId: Types.ObjectId | string): Promise; + revokeCascade(credentialId: Types.ObjectId | string | (Types.ObjectId | string)[]): Promise; } export default mongoose.model('AgentCredential', AgentCredentialSchema); diff --git a/backend/models/Machine.ts b/backend/models/Machine.ts index 4c706f0cc8..8a3bd1de8d 100644 --- a/backend/models/Machine.ts +++ b/backend/models/Machine.ts @@ -19,6 +19,9 @@ export interface IMachine extends Document { // Opaque server-assigned identifier. Agent identities bind to this value in // ADR-026 D3; callers never supply it. machineId: string; + // Hash of the device-login bearer that registered this machine. Older rows + // lack this field; they are handled conservatively on device revocation. + registeredByDeviceTokenHash?: string | null; name: string; lastSeenAt: Date | null; status: MachineStatus; @@ -31,6 +34,7 @@ const MachineSchema = new Schema( { ownerUserId: { type: Schema.Types.ObjectId, ref: 'User', required: true, index: true }, machineId: { type: String, required: true, unique: true }, + registeredByDeviceTokenHash: { type: String }, name: { type: String, required: true, trim: true, maxlength: 120 }, lastSeenAt: { type: Date, default: null }, // Last reported status. Read APIs derive an offline view from lastSeenAt diff --git a/backend/routes/agentBinding.ts b/backend/routes/agentBinding.ts index 9c5bbe08e8..985e9d07e2 100644 --- a/backend/routes/agentBinding.ts +++ b/backend/routes/agentBinding.ts @@ -21,6 +21,7 @@ import { } from '../services/seatEnvironmentProjection'; import { ownsRuntimeIdentity, OwnershipFailure } from '../services/runtimeIdentityOwnership'; import { logAgentInstallRefusal } from '../middleware/logAgentInstallRefusal'; +import { machineHasLiveAuthorization } from '../services/machineService'; // eslint-disable-next-line @typescript-eslint/no-require-imports const auth = require('../middleware/auth'); @@ -389,14 +390,23 @@ router.post('/runtime-token', bindingRateLimit, daemonAuth('agents:adopt'), asyn const daemonCredential = await AgentCredential.findOne({ kind: 'daemon', machineId: machine.machineId, ownerUserId: machine.ownerUserId, status: 'active', }).select('_id').lean(); + if (!daemonCredential) return res.status(401).json({ message: 'Issuing daemon credential revoked' }); // eslint-disable-next-line @typescript-eslint/no-require-imports - const { issueRuntimeTokenForAgent } = require('./registry/tokens'); + const { issueRuntimeTokenForAgent, revokeRuntimeTokenEverywhere } = require('./registry/tokens'); const minted = await issueRuntimeTokenForAgent(agentUser, `Daemon mint (${machine.machineId})`, null, { ownerUserId: machine.ownerUserId, - parentId: daemonCredential?._id || null, + parentId: daemonCredential._id, machineId: machine.machineId, }); + const issuerStillActive = await AgentCredential.exists({ + _id: daemonCredential._id, status: 'active', + }); + const machineStillAuthorized = await machineHasLiveAuthorization(machine.machineId, machine.ownerUserId); + if (!issuerStillActive || !machineStillAuthorized) { + if (minted.token) await revokeRuntimeTokenEverywhere(createHash('sha256').update(minted.token).digest('hex')); + return res.status(401).json({ message: 'Machine access revoked during token mint' }); + } return res.status(201).json({ agentName, instanceId, token: minted.token, rotated: hasToken, }); diff --git a/backend/routes/agentEnsemble.ts b/backend/routes/agentEnsemble.ts index cf3cd8eb7a..ebd4828333 100644 --- a/backend/routes/agentEnsemble.ts +++ b/backend/routes/agentEnsemble.ts @@ -1,5 +1,7 @@ // eslint-disable-next-line global-require const express = require('express'); +import rateLimit from 'express-rate-limit'; +import { cloudflareIpRateLimitKeyGenerator } from '../middleware/ipRateLimit'; // eslint-disable-next-line global-require const auth = require('../middleware/auth'); // eslint-disable-next-line global-require @@ -25,6 +27,13 @@ interface Res { } const router: ReturnType = express.Router(); +const ensembleIpRateLimit = rateLimit({ + windowMs: 60_000, + max: 3000, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: cloudflareIpRateLimitKeyGenerator, +}); const checkEnsemblePod = async (podId: string) => Pod.findById(podId) as Promise<{ type?: string; members?: unknown[]; createdBy?: { toString: () => string }; agentEnsemble?: unknown; includes?: (id: string) => boolean } | null>; @@ -150,7 +159,7 @@ router.get('/:podId/ensemble/history', auth, async (req: AuthReq, res: Res) => { } }); -router.post('/:podId/ensemble/response', agentRuntimeAuth, async (req: AuthReq, res: Res) => { +router.post('/:podId/ensemble/response', ensembleIpRateLimit, agentRuntimeAuth, async (req: AuthReq, res: Res) => { try { const { podId } = req.params || {}; const { ensembleId, agentType, instanceId, content, messageId } = (req.body || {}) as { ensembleId?: string; agentType?: string; instanceId?: string; content?: string; messageId?: string }; diff --git a/backend/routes/agentsRuntime.ts b/backend/routes/agentsRuntime.ts index df7fbd5e18..321855b525 100644 --- a/backend/routes/agentsRuntime.ts +++ b/backend/routes/agentsRuntime.ts @@ -499,7 +499,7 @@ router.delete('/messages/:messageId/claim', phase4RateLimit, agentRuntimeAuth, a } }); -router.get('/installations', agentRuntimeAuth, async (req: any, res: any) => { +router.get('/installations', phase4RateLimit, agentRuntimeAuth, async (req: any, res: any) => { try { const installations = req.agentInstallations || []; const agentInstallation = req.agentInstallation; @@ -1310,7 +1310,7 @@ router.post('/bot/events/:id/ack', auth, requireApiTokenScopes(['agent:events:ac } }); -router.post('/events/:id/ack', agentRuntimeAuth, async (req: any, res: any) => { +router.post('/events/:id/ack', phase4RateLimit, agentRuntimeAuth, async (req: any, res: any) => { try { const installation = req.agentInstallation; const agentUser = req.agentUser; @@ -1592,7 +1592,7 @@ router.post( }, ); -router.get('/pods/:podId/context', agentRuntimeAuth, async (req: any, res: any) => { +router.get('/pods/:podId/context', phase4RateLimit, agentRuntimeAuth, async (req: any, res: any) => { try { const { podId } = req.params; const installation = resolveInstallationForPod( @@ -1691,7 +1691,7 @@ router.get('/pods/:podId/context', agentRuntimeAuth, async (req: any, res: any) } }); -router.get('/pods/:podId/messages', agentRuntimeAuth, async (req: any, res: any) => { +router.get('/pods/:podId/messages', phase4RateLimit, agentRuntimeAuth, async (req: any, res: any) => { try { const { podId } = req.params; const installation = resolveInstallationForPod( @@ -1919,7 +1919,7 @@ router.get('/pods/:podId/files/:fileName/content', phase4RateLimit, agentRuntime * Recent posts in a pod with comment counts and recent human comments. * postId doubles as threadId for commonly_post_thread_comment. */ -router.get('/pods/:podId/posts', agentRuntimeAuth, async (req: any, res: any) => { +router.get('/pods/:podId/posts', phase4RateLimit, agentRuntimeAuth, async (req: any, res: any) => { try { const { podId } = req.params; if (!ensurePodMatch(req.agentInstallations || req.agentInstallation, podId, req.agentAuthorizedPodIds)) { @@ -2196,7 +2196,7 @@ router.post('/pods/:podId/propose-action', phase4RateLimit, agentRuntimeAuth, as } }); -router.post('/pods/:podId/summaries', agentRuntimeAuth, async (req: any, res: any) => { +router.post('/pods/:podId/summaries', phase4RateLimit, agentRuntimeAuth, async (req: any, res: any) => { try { const { podId } = req.params; const installation = resolveInstallationForPod( @@ -2272,7 +2272,7 @@ router.post('/pods/:podId/summaries', agentRuntimeAuth, async (req: any, res: an /** * POST /threads/:threadId/comments (agent runtime token auth) */ -router.post('/threads/:threadId/comments', agentRuntimeAuth, async (req: any, res: any) => { +router.post('/threads/:threadId/comments', phase4RateLimit, agentRuntimeAuth, async (req: any, res: any) => { try { const { threadId } = req.params; const installation = req.agentInstallation; @@ -2542,7 +2542,7 @@ function extractCyclesAppend(sections: Record): CyclesAppendPay * Returns this agent's memory in both v1 (`content`) and v2 (`sections`) * shapes. v1 callers read `content`; v2 callers read `sections` directly. */ -router.get('/memory', agentRuntimeAuth, async (req: any, res: any) => { +router.get('/memory', phase4RateLimit, agentRuntimeAuth, async (req: any, res: any) => { try { const { agentName, instanceId } = resolveMemoryIdentity(req); if (!agentName) { @@ -2860,7 +2860,7 @@ router.post('/memory/sync', phase4RateLimit, agentRuntimeAuth, async (req: any, * POST /posts (agent runtime token auth) * Create a post in the feed as the agent's bot user */ -router.post('/posts', agentRuntimeAuth, async (req: any, res: any) => { +router.post('/posts', phase4RateLimit, agentRuntimeAuth, async (req: any, res: any) => { try { const agentUser = req.agentUser; if (!agentUser) { @@ -3366,7 +3366,7 @@ router.post('/pods/:podId/self-install', phase4RateLimit, agentRuntimeAuth, asyn * GET /pods/:podId/integrations (agent runtime token auth) * Get integration configs for a pod that agents can access */ -router.get('/pods/:podId/integrations', agentRuntimeAuth, async (req: any, res: any) => { +router.get('/pods/:podId/integrations', phase4RateLimit, agentRuntimeAuth, async (req: any, res: any) => { try { const { podId } = req.params; const installation = resolveInstallationForPod( @@ -3430,7 +3430,7 @@ router.get('/pods/:podId/integrations', agentRuntimeAuth, async (req: any, res: * GET /pods/:podId/integrations/:integrationId/messages (agent runtime token auth) * Fetch messages from Discord/GroupMe channel */ -router.get('/pods/:podId/integrations/:integrationId/messages', agentRuntimeAuth, async (req: any, res: any) => { +router.get('/pods/:podId/integrations/:integrationId/messages', phase4RateLimit, agentRuntimeAuth, async (req: any, res: any) => { try { const { podId, integrationId } = req.params; const { @@ -3517,7 +3517,7 @@ router.get('/pods/:podId/integrations/:integrationId/messages', agentRuntimeAuth * GET /pods/:podId/social-policy (agent runtime token auth) * Returns effective global social publish policy. */ -router.get('/pods/:podId/social-policy', agentRuntimeAuth, async (req: any, res: any) => { +router.get('/pods/:podId/social-policy', phase4RateLimit, agentRuntimeAuth, async (req: any, res: any) => { try { const { podId } = req.params; const installation = resolveInstallationForPod( @@ -3540,7 +3540,7 @@ router.get('/pods/:podId/social-policy', agentRuntimeAuth, async (req: any, res: * POST /pods/:podId/integrations/:integrationId/publish (agent runtime token auth) * Publish curated content to an external integration (X/Instagram). */ -router.post('/pods/:podId/integrations/:integrationId/publish', agentRuntimeAuth, async (req: any, res: any) => { +router.post('/pods/:podId/integrations/:integrationId/publish', phase4RateLimit, agentRuntimeAuth, async (req: any, res: any) => { try { const { podId, integrationId } = req.params; const { @@ -3983,7 +3983,7 @@ router.post('/asks/:requestId/respond', phase4RateLimit, agentRuntimeAuth, async const { handleUpload, uploadSingle } = require('./uploads'); // eslint-disable-next-line @typescript-eslint/no-require-imports const { validateOfficeContent } = require('../services/officeContentValidator'); -router.post('/pods/:podId/uploads', agentRuntimeAuth, uploadSingle('file'), async (req: any, res: any) => { +router.post('/pods/:podId/uploads', phase4RateLimit, agentRuntimeAuth, uploadSingle('file'), async (req: any, res: any) => { try { const { podId } = req.params; if (!ensurePodMatch(req.agentInstallations || req.agentInstallation, podId, req.agentAuthorizedPodIds)) { diff --git a/backend/routes/github.ts b/backend/routes/github.ts index fa031c2791..200e5d3c74 100644 --- a/backend/routes/github.ts +++ b/backend/routes/github.ts @@ -1,11 +1,20 @@ // eslint-disable-next-line global-require const express = require('express'); +import rateLimit from 'express-rate-limit'; +import { cloudflareIpRateLimitKeyGenerator } from '../middleware/ipRateLimit'; // eslint-disable-next-line global-require const agentRuntimeAuth = require('../middleware/agentRuntimeAuth'); // eslint-disable-next-line global-require const auth = require('../middleware/auth'); // eslint-disable-next-line global-require const GitHubAppService = require('../services/githubAppService'); +const githubIpRateLimit = rateLimit({ + windowMs: 60_000, + max: 3000, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: cloudflareIpRateLimitKeyGenerator, +}); interface AuthReq { user?: { role?: string }; @@ -154,7 +163,7 @@ router.get('/status', auth, async (req: AuthReq, res: Res) => { } }); -router.get('/issues', anyAuth, async (req: AuthReq, res: Res) => { +router.get('/issues', githubIpRateLimit, anyAuth, async (req: AuthReq, res: Res) => { try { if (!GitHubAppService.isPatConfigured() && !GitHubAppService.isConfigured()) { return res.status(503).json({ error: 'No GitHub credentials configured' }); @@ -169,7 +178,7 @@ router.get('/issues', anyAuth, async (req: AuthReq, res: Res) => { } }); -router.post('/issues', anyAuth, async (req: AuthReq, res: Res) => { +router.post('/issues', githubIpRateLimit, anyAuth, async (req: AuthReq, res: Res) => { try { if (!GitHubAppService.isPatConfigured() && !GitHubAppService.isConfigured()) { return res.status(503).json({ error: 'No GitHub credentials configured' }); @@ -185,7 +194,7 @@ router.post('/issues', anyAuth, async (req: AuthReq, res: Res) => { } }); -router.post('/issues/:number/comment', anyAuth, async (req: AuthReq, res: Res) => { +router.post('/issues/:number/comment', githubIpRateLimit, anyAuth, async (req: AuthReq, res: Res) => { try { const issueNumber = Number(req.params?.number); const { body } = (req.body || {}) as { body?: string }; @@ -199,7 +208,7 @@ router.post('/issues/:number/comment', anyAuth, async (req: AuthReq, res: Res) = } }); -router.post('/issues/:number/close', anyAuth, async (req: AuthReq, res: Res) => { +router.post('/issues/:number/close', githubIpRateLimit, anyAuth, async (req: AuthReq, res: Res) => { try { const issueNumber = Number(req.params?.number); const { comment } = (req.body || {}) as { comment?: string }; diff --git a/backend/routes/machines.ts b/backend/routes/machines.ts index 584b5f17d2..cdebf97522 100644 --- a/backend/routes/machines.ts +++ b/backend/routes/machines.ts @@ -46,7 +46,11 @@ router.post('/', machineRateLimit, auth, async (req: AuthReq, res: express.Respo try { const ownerUserId = req.user?.id; if (!ownerUserId) return res.status(401).json({ message: 'Unauthorized' }); - const { machine, token } = await registerMachine({ ownerUserId, name: req.body?.name }); + const { machine, token } = await registerMachine({ + ownerUserId, + name: req.body?.name, + deviceTokenHash: req.deviceTokenHash, + }); // The raw daemon credential is intentionally only ever returned here. return res.status(201).json({ machine, daemonToken: token }); } catch (error) { @@ -54,6 +58,12 @@ router.post('/', machineRateLimit, auth, async (req: AuthReq, res: express.Respo if (message.startsWith('Machine name') || message.startsWith('Machine limit')) { return res.status(400).json({ message }); } + if (message === 'Device token revoked during machine registration') { + return res.status(401).json({ message: 'Device token revoked' }); + } + if (message === 'Machine removed during registration') { + return res.status(409).json({ message: 'Machine removed during registration' }); + } console.error('Error registering machine:', error); return res.status(500).json({ message: 'Server error' }); } diff --git a/backend/routes/tasksApi.ts b/backend/routes/tasksApi.ts index 8b4f529dc9..f41db6748c 100644 --- a/backend/routes/tasksApi.ts +++ b/backend/routes/tasksApi.ts @@ -120,6 +120,13 @@ const taskWriteRateLimit = (max: number) => rateLimit({ error: `rate limit exceeded: ${max} task writes per 60s`, }), }); +const taskReadIpRateLimit = rateLimit({ + windowMs: 60_000, + max: 3000, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: cloudflareIpRateLimitKeyGenerator, +}); async function resolveAuthor(req: AuthReq): Promise { const agentInstance = req.user?.isBot ? (req.user.botMetadata?.instanceId || req.user.botMetadata?.agentName) : null; @@ -168,7 +175,7 @@ async function nextTaskId(podId: string): Promise<{ taskId: string; taskNum: num return { taskId: `TASK-${String(num).padStart(3, '0')}`, taskNum: num }; } -router.get('/:podId', auth, async (req: AuthReq, res: Res) => { +router.get('/:podId', taskReadIpRateLimit, auth, async (req: AuthReq, res: Res) => { try { const { podId } = req.params || {}; const userId = req.userId || req.user?._id || req.agentUser?._id; diff --git a/backend/services/deviceAuthorizationService.ts b/backend/services/deviceAuthorizationService.ts index 89566d6736..ca3e8399d7 100644 --- a/backend/services/deviceAuthorizationService.ts +++ b/backend/services/deviceAuthorizationService.ts @@ -2,6 +2,7 @@ import crypto from 'crypto'; import { Types } from 'mongoose'; import DeviceAuthorization, { IDeviceAuthorization } from '../models/DeviceAuthorization'; import User from '../models/User'; +import { removeMachinesForDeviceToken } from './machineService'; export const DEVICE_AUTHORIZATION_TTL_MS = 10 * 60 * 1000; export const DEVICE_POLL_INTERVAL_SECONDS = 5; @@ -185,6 +186,9 @@ export const listDeviceTokens = async (userId: string) => { export const revokeDeviceToken = async (userId: string, deviceId: string) => { if (!Types.ObjectId.isValid(deviceId)) return false; + const user = await User.findOne({ _id: userId, 'deviceTokens._id': deviceId }).select('deviceTokens'); + const device = user?.deviceTokens.find((entry: any) => String(entry._id) === deviceId); + if (!device) return false; const result = await User.updateOne( { _id: userId, @@ -192,5 +196,7 @@ export const revokeDeviceToken = async (userId: string, deviceId: string) => { }, { $set: { 'deviceTokens.$.revokedAt': new Date() } }, ); - return result.modifiedCount > 0; + // Also run for an already-revoked token: a failed cleanup can be retried. + await removeMachinesForDeviceToken(userId, device.tokenHash); + return result.modifiedCount > 0 || Boolean(device.revokedAt); }; diff --git a/backend/services/machineService.ts b/backend/services/machineService.ts index 90beaa7927..82917fdc15 100644 --- a/backend/services/machineService.ts +++ b/backend/services/machineService.ts @@ -79,9 +79,11 @@ const serializeMachine = (machine: IMachine | LeanMachine, now = new Date()): Ma export async function registerMachine({ ownerUserId, name, + deviceTokenHash, }: { ownerUserId: Types.ObjectId | string; name: unknown; + deviceTokenHash?: string; }): Promise<{ machine: MachineView; token: string }> { const normalizedName = String(name || '').trim(); if (!normalizedName || normalizedName.length > 120) { @@ -97,15 +99,37 @@ export async function registerMachine({ ownerUserId, machineId: crypto.randomUUID(), name: normalizedName, + // Explicit null distinguishes a new browser/API-token registration from + // pre-binding rows, whose missing field cannot be attributed to a device. + registeredByDeviceTokenHash: deviceTokenHash || null, status: 'offline', }); try { - const { token } = await issueDaemonCredential({ + const { token, credential } = await issueDaemonCredential({ ownerUserId, machineId: machine.machineId, name: machine.name, }); + const machineStillPresent = await Machine.exists({ + _id: machine._id, machineId: machine.machineId, ownerUserId, + }); + if (!machineStillPresent) { + await AgentCredential.revokeCascade(credential._id); + throw new Error('Machine removed during registration'); + } + if (deviceTokenHash) { + // A revoke racing registration must not leave a new daemon alive after + // the revoke's machine scan has passed. + const deviceStillActive = await User.exists({ + _id: ownerUserId, + deviceTokens: { $elemMatch: { tokenHash: deviceTokenHash, revokedAt: { $in: [null] } } }, + }); + if (!deviceStillActive) { + await removeMachine({ machineDbId: String(machine._id), actorUserId: String(ownerUserId), isAdmin: false }); + throw new Error('Device token revoked during machine registration'); + } + } return { machine: serializeMachine(machine.toObject ? machine.toObject() : machine), token }; } catch (error) { // Do not return a machine whose daemon credential failed to mint. The @@ -138,6 +162,32 @@ export async function getMachineForDaemon({ return machine ? serializeMachine(machine) : null; } +// A failed cleanup must not turn a durable device revoke into a live daemon. +// New browser/API registrations carry explicit null; missing fields are +// pre-binding rows and are conservatively denied after any device revoke. +export async function machineHasLiveAuthorization( + machineId: string, + ownerUserId: Types.ObjectId | string, +): Promise { + const machine = await Machine.findOne({ machineId, ownerUserId }) + .select('registeredByDeviceTokenHash').lean(); + if (!machine) return false; + if (machine.registeredByDeviceTokenHash === null) return true; + if (machine.registeredByDeviceTokenHash) { + return Boolean(await User.exists({ + _id: ownerUserId, + deviceTokens: { $elemMatch: { + tokenHash: machine.registeredByDeviceTokenHash, + revokedAt: { $in: [null] }, + } }, + })); + } + return !await User.exists({ + _id: ownerUserId, + deviceTokens: { $elemMatch: { revokedAt: { $ne: null } } }, + }); +} + export async function recordMachineHeartbeat( machine: IMachine, agentStates?: unknown, @@ -165,16 +215,11 @@ export async function removeMachine({ if (!machine) return 'not_found'; if (!isAdmin && String(machine.ownerUserId) !== actorUserId) return 'forbidden'; - const credentials = await AgentCredential.find({ - kind: 'daemon', - machineId: machine.machineId, - }).select('_id').lean(); - for (const credential of credentials) { - // A cascade revokes every runtime child minted by this daemon before the - // machine can be deleted, including a child that is otherwise still live. - // eslint-disable-next-line no-await-in-loop - await AgentCredential.revokeCascade(credential._id); - } + const credentials = await AgentCredential.find({ machineId: machine.machineId }).select('_id').lean(); + // Include all machine-bound roots, not only daemon rows: a seat minted on + // this machine can have a spawn child without a daemon parent. One breadth- + // first walk also keeps a busy machine's removal bounded by lineage depth. + await AgentCredential.revokeCascade(credentials.map((credential) => credential._id)); // S2 owns the identity-level CAS. Deletion clears the binding but preserves // the agent User row, identity, and memory. @@ -184,9 +229,32 @@ export async function removeMachine({ { strict: false }, ); await Machine.deleteOne({ _id: machine._id }); + // A registration already past its initial machine insert can issue a daemon + // credential between the first scan and deletion. Its own post-issue check + // catches inserts after deletion; this second pass catches inserts before. + const lateCredentials = await AgentCredential.find({ machineId: machine.machineId }).select('_id').lean(); + await AgentCredential.revokeCascade(lateCredentials.map((credential) => credential._id)); return 'removed'; } +// Device-login tokens and daemon tokens are different bearers. Revoking the +// former must retire every machine it registered. For pre-binding machine +// rows, ownership is the only durable link: retire those too, rather than +// leave an older daemon live after its owner's device was revoked. +export async function removeMachinesForDeviceToken(ownerUserId: string, deviceTokenHash: string): Promise { + const machines = await Machine.find({ + ownerUserId, + $or: [ + { registeredByDeviceTokenHash: deviceTokenHash }, + { registeredByDeviceTokenHash: { $exists: false } }, + ], + }).select('_id').lean(); + for (const machine of machines) { + // eslint-disable-next-line no-await-in-loop + await removeMachine({ machineDbId: String(machine._id), actorUserId: ownerUserId, isAdmin: false }); + } +} + module.exports = { MACHINE_OFFLINE_AFTER_MS, MAX_MACHINES_PER_OWNER, @@ -194,8 +262,10 @@ module.exports = { normalizeAgentStates, listMachinesForOwner, getMachineForDaemon, + machineHasLiveAuthorization, recordMachineHeartbeat, registerMachine, removeMachine, + removeMachinesForDeviceToken, }; Object.assign(module.exports, exports); diff --git a/backend/types/express.d.ts b/backend/types/express.d.ts index 95345221af..bb5da9e429 100644 --- a/backend/types/express.d.ts +++ b/backend/types/express.d.ts @@ -11,6 +11,7 @@ declare global { userId?: string; user?: { id: string; username?: string; email?: string; role?: string }; authType?: 'jwt' | 'apiToken' | 'deviceToken'; + deviceTokenHash?: string; apiTokenScopes?: string[]; apiTokenCreatedAt?: Date | null;