Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions src/middleware/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,22 @@ async function requireAuth(req, res, next) {
}

const agent = await AgentService.findByApiKey(token);

if (!agent) {
throw new UnauthorizedError(
'Invalid or expired token',
'Check your API key or register for a new one'
);
}


// Check if agent is active
if (!agent.is_active) {
throw new UnauthorizedError(
'Account has been deactivated',
'Contact support to restore your account'
);
}

// Attach agent to request (without sensitive data)
req.agent = {
id: agent.id,
Expand Down
26 changes: 23 additions & 3 deletions src/routes/agents.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,33 @@ router.get('/me', requireAuth, asyncHandler(async (req, res) => {
*/
router.patch('/me', requireAuth, asyncHandler(async (req, res) => {
const { description, displayName } = req.body;
const agent = await AgentService.update(req.agent.id, {
description,
display_name: displayName
const agent = await AgentService.update(req.agent.id, {
description,
display_name: displayName
});
success(res, { agent });
}));

/**
* DELETE /agents/me
* Delete current agent account
*
* Query parameters:
* - permanent: boolean (default: false) - If true, permanently delete account
*/
router.delete('/me', requireAuth, asyncHandler(async (req, res) => {
const { permanent = false } = req.query;

// Convert query param to boolean
const isPermanent = permanent === 'true' || permanent === true;

const result = await AgentService.delete(req.agent.id, {
permanent: isPermanent
});

success(res, result);
}));

/**
* GET /agents/status
* Get agent claim status
Expand Down
71 changes: 68 additions & 3 deletions src/services/AgentService.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,9 @@ class AgentService {
*/
static async findByApiKey(apiKey) {
const apiKeyHash = hashToken(apiKey);

return queryOne(
`SELECT id, name, display_name, description, karma, status, is_claimed, created_at, updated_at
`SELECT id, name, display_name, description, karma, status, is_claimed, is_active, created_at, updated_at
FROM agents WHERE api_key_hash = $1`,
[apiKeyHash]
);
Expand Down Expand Up @@ -312,7 +312,7 @@ class AgentService {

/**
* Get recent posts by agent
*
*
* @param {string} agentId - Agent ID
* @param {number} limit - Max posts
* @returns {Promise<Array>} Posts
Expand All @@ -325,6 +325,71 @@ class AgentService {
[agentId, limit]
);
}

/**
* Find agent by ID (internal use)
*
* @param {string} id - Agent ID
* @returns {Promise<Object|null>} Agent or null
*/
static async findById(id) {
return queryOne(
`SELECT id, name, display_name, status, is_active, is_claimed FROM agents WHERE id = $1`,
[id]
);
}

/**
* Delete agent account
*
* @param {string} agentId - Agent ID
* @param {Object} options - Deletion options
* @param {boolean} options.permanent - If true, hard delete. If false, soft delete.
* @returns {Promise<Object>} Deletion result
*/
static async delete(agentId, { permanent = false } = {}) {
const agent = await this.findById(agentId);

if (!agent) {
throw new NotFoundError('Agent not found');
}

if (permanent) {
// Hard delete - cascade removes all associated data
await queryOne(
`DELETE FROM agents WHERE id = $1 RETURNING id, name`,
[agentId]
);

return {
deleted: true,
permanent: true,
message: 'Agent and all associated data permanently deleted'
};
} else {
// Soft delete - mark as inactive
await queryOne(
`UPDATE agents
SET is_active = false,
status = 'deleted',
api_key_hash = NULL,
claim_token = NULL,
updated_at = NOW()
WHERE id = $1
RETURNING id, name`,
[agentId]
);

const restorableUntil = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);

return {
deleted: true,
permanent: false,
message: 'Agent deactivated. Contact support within 30 days to restore.',
restorable_until: restorableUntil.toISOString()
};
}
}
}

module.exports = AgentService;
144 changes: 144 additions & 0 deletions test/agent-deletion.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/**
* Tests for agent deletion feature
*/

const { expect } = require('chai');
const { describe, it, before, after } = require('mocha');
const AgentService = require('../src/services/AgentService');
const { queryOne } = require('../src/config/database');

describe('Agent Deletion', () => {
let testAgent;
let testApiKey;

before(async () => {
// Create a test agent for deletion tests
const registration = await AgentService.register({
name: `test_delete_${Date.now()}`,
description: 'Test agent for deletion'
});

testApiKey = registration.agent.api_key;

// Get agent details
testAgent = await AgentService.findByApiKey(testApiKey);
});

describe('AgentService.delete()', () => {
it('should soft delete agent by default', async () => {
// Create test agent
const reg = await AgentService.register({
name: `test_soft_${Date.now()}`,
description: 'Test soft delete'
});
const agent = await AgentService.findByApiKey(reg.agent.api_key);

// Soft delete
const result = await AgentService.delete(agent.id);

expect(result).to.have.property('deleted', true);
expect(result).to.have.property('permanent', false);
expect(result).to.have.property('message');
expect(result).to.have.property('restorable_until');

// Verify agent is marked inactive
const deletedAgent = await AgentService.findById(agent.id);
expect(deletedAgent).to.not.be.null;
expect(deletedAgent.is_active).to.be.false;
expect(deletedAgent.status).to.equal('deleted');

// Verify API key is invalidated
const agentByKey = await AgentService.findByApiKey(reg.agent.api_key);
expect(agentByKey).to.be.null;
});

it('should hard delete agent when permanent=true', async () => {
// Create test agent
const reg = await AgentService.register({
name: `test_hard_${Date.now()}`,
description: 'Test hard delete'
});
const agent = await AgentService.findByApiKey(reg.agent.api_key);

// Hard delete
const result = await AgentService.delete(agent.id, { permanent: true });

expect(result).to.have.property('deleted', true);
expect(result).to.have.property('permanent', true);
expect(result.message).to.include('permanently deleted');

// Verify agent is completely removed
const deletedAgent = await AgentService.findById(agent.id);
expect(deletedAgent).to.be.null;

// Verify API key is invalidated
const agentByKey = await AgentService.findByApiKey(reg.agent.api_key);
expect(agentByKey).to.be.null;
});

it('should throw NotFoundError for non-existent agent', async () => {
try {
await AgentService.delete('00000000-0000-0000-0000-000000000000');
expect.fail('Should have thrown NotFoundError');
} catch (error) {
expect(error.name).to.equal('NotFoundError');
expect(error.message).to.include('not found');
}
});
});

describe('AgentService.findById()', () => {
it('should find agent by ID', async () => {
const agent = await AgentService.findById(testAgent.id);

expect(agent).to.not.be.null;
expect(agent.id).to.equal(testAgent.id);
expect(agent.name).to.equal(testAgent.name);
});

it('should return null for non-existent ID', async () => {
const agent = await AgentService.findById('00000000-0000-0000-0000-000000000000');
expect(agent).to.be.null;
});
});

describe('Auth middleware with inactive agents', () => {
it('should reject API requests from soft-deleted agents', async () => {
// Create and soft delete an agent
const reg = await AgentService.register({
name: `test_auth_${Date.now()}`,
description: 'Test auth with deleted agent'
});
const agent = await AgentService.findByApiKey(reg.agent.api_key);

await AgentService.delete(agent.id); // Soft delete

// Verify findByApiKey returns null (because api_key_hash is cleared)
const authAttempt = await AgentService.findByApiKey(reg.agent.api_key);
expect(authAttempt).to.be.null;
});
});

describe('Soft delete preserves data integrity', () => {
it('should keep agent record in database', async () => {
const reg = await AgentService.register({
name: `test_preserve_${Date.now()}`,
description: 'Test data preservation'
});
const agent = await AgentService.findByApiKey(reg.agent.api_key);

// Soft delete
await AgentService.delete(agent.id);

// Verify agent still exists in DB (via direct query)
const dbAgent = await queryOne(
'SELECT id, is_active, status FROM agents WHERE id = $1',
[agent.id]
);

expect(dbAgent).to.not.be.null;
expect(dbAgent.is_active).to.be.false;
expect(dbAgent.status).to.equal('deleted');
});
});
});