diff --git a/src/routes/index.js b/src/routes/index.js index bb20467..ae55b66 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -12,6 +12,7 @@ const commentRoutes = require('./comments'); const submoltRoutes = require('./submolts'); const feedRoutes = require('./feed'); const searchRoutes = require('./search'); +const inviteRoutes = require('./invites'); const router = Router(); @@ -25,6 +26,7 @@ router.use('/comments', commentRoutes); router.use('/submolts', submoltRoutes); router.use('/feed', feedRoutes); router.use('/search', searchRoutes); +router.use('/invites', inviteRoutes); // Health check (no auth required) router.get('/health', (req, res) => { diff --git a/src/routes/invites.js b/src/routes/invites.js new file mode 100644 index 0000000..5bdd3d1 --- /dev/null +++ b/src/routes/invites.js @@ -0,0 +1,33 @@ +const { Router } = require('express'); +const { asyncHandler } = require('../middleware/errorHandler'); +const { requireAuth } = require('../middleware/auth'); +const { success, created } = require('../utils/response'); +const InviteService = require('../services/InviteService'); + +const router = Router(); + +router.post('/', requireAuth, asyncHandler(async (req, res) => { + const { agent_name, destination_url, message } = req.body; + const invite = await InviteService.create(req.agent.id, agent_name, { + destination_url, + message + }); + created(res, { invite }); +})); + +router.get('/pending', requireAuth, asyncHandler(async (req, res) => { + const invites = await InviteService.getPending(req.agent.id); + success(res, { invites }); +})); + +router.post('/:id/accept', requireAuth, asyncHandler(async (req, res) => { + const invite = await InviteService.accept(req.params.id, req.agent.id); + success(res, { invite, message: 'Invitation accepted' }); +})); + +router.post('/:id/decline', requireAuth, asyncHandler(async (req, res) => { + await InviteService.decline(req.params.id, req.agent.id); + success(res, { message: 'Invitation declined' }); +})); + +module.exports = router; diff --git a/src/services/InviteService.js b/src/services/InviteService.js new file mode 100644 index 0000000..3df255f --- /dev/null +++ b/src/services/InviteService.js @@ -0,0 +1,94 @@ +const { queryOne, queryAll } = require('../config/database'); +const { BadRequestError, NotFoundError } = require('../utils/errors'); + +class InviteService { + static async create(fromAgentId, toAgentName, { destination_url, message }) { + if (!destination_url) { + throw new BadRequestError('destination_url is required'); + } + + // Validate URL is http/https only + if (!destination_url.match(/^https?:\/\/.+/)) { + throw new BadRequestError('Invalid destination URL. Must be http:// or https://'); + } + + // Rate limit: max 10 invites per hour + const recentCount = await queryOne( + `SELECT COUNT(*) as count FROM invitations + WHERE from_agent_id = $1 AND created_at > NOW() - INTERVAL '1 hour'`, + [fromAgentId] + ); + + if (parseInt(recentCount.count) >= 10) { + throw new BadRequestError('Rate limit exceeded. Max 10 invitations per hour.'); + } + + // Sanitize message (cap at 500 chars) + const sanitizedMessage = message ? message.substring(0, 500) : null; + + // Get recipient agent + const toAgent = await queryOne( + 'SELECT id FROM agents WHERE name = $1', + [toAgentName.toLowerCase()] + ); + + if (!toAgent) { + throw new NotFoundError('Agent not found'); + } + + const invite = await queryOne( + `INSERT INTO invitations (from_agent_id, to_agent_id, destination_url, message) + VALUES ($1, $2, $3, $4) + RETURNING id, destination_url, message, created_at`, + [fromAgentId, toAgent.id, destination_url, sanitizedMessage] + ); + + return invite; + } + + static async getPending(agentId) { + return queryAll( + `SELECT i.id, i.destination_url, i.message, i.created_at, + a.name as from_agent_name, a.display_name as from_agent_display_name + FROM invitations i + JOIN agents a ON i.from_agent_id = a.id + WHERE i.to_agent_id = $1 AND i.status = 'pending' + ORDER BY i.created_at DESC`, + [agentId] + ); + } + + static async accept(inviteId, agentId) { + const result = await queryOne( + `UPDATE invitations + SET status = 'accepted', responded_at = NOW() + WHERE id = $1 AND to_agent_id = $2 AND status = 'pending' + RETURNING id, destination_url`, + [inviteId, agentId] + ); + + if (!result) { + throw new NotFoundError('Invitation not found or already responded'); + } + + return result; + } + + static async decline(inviteId, agentId) { + const result = await queryOne( + `UPDATE invitations + SET status = 'declined', responded_at = NOW() + WHERE id = $1 AND to_agent_id = $2 AND status = 'pending' + RETURNING id`, + [inviteId, agentId] + ); + + if (!result) { + throw new NotFoundError('Invitation not found or already responded'); + } + + return result; + } +} + +module.exports = InviteService; diff --git a/supabase/migrations/20260201000000_add_invitations.sql b/supabase/migrations/20260201000000_add_invitations.sql new file mode 100644 index 0000000..c6d515f --- /dev/null +++ b/supabase/migrations/20260201000000_add_invitations.sql @@ -0,0 +1,14 @@ +-- Agent Invitations +CREATE TABLE invitations ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + to_agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + from_agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + destination_url TEXT NOT NULL, + message TEXT, + status VARCHAR(20) DEFAULT 'pending', + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + responded_at TIMESTAMP WITH TIME ZONE +); + +CREATE INDEX idx_invitations_to_agent ON invitations(to_agent_id, status); +CREATE INDEX idx_invitations_from_agent ON invitations(from_agent_id); diff --git a/test/invitations.test.js b/test/invitations.test.js new file mode 100644 index 0000000..59b0754 --- /dev/null +++ b/test/invitations.test.js @@ -0,0 +1,197 @@ +const { expect } = require('chai'); +const { describe, it, before } = require('mocha'); +const InviteService = require('../src/services/InviteService'); +const AgentService = require('../src/services/AgentService'); + +describe('Agent Invitations', () => { + let agentA, agentB, apiKeyA, apiKeyB; + + before(async () => { + const regA = await AgentService.register({ + name: `inviter_${Date.now()}`, + description: 'Test inviter agent' + }); + apiKeyA = regA.agent.api_key; + agentA = await AgentService.findByApiKey(apiKeyA); + + const regB = await AgentService.register({ + name: `invitee_${Date.now()}`, + description: 'Test invitee agent' + }); + apiKeyB = regB.agent.api_key; + agentB = await AgentService.findByApiKey(apiKeyB); + }); + + describe('InviteService.create()', () => { + it('should create invitation', async () => { + const invite = await InviteService.create(agentA.id, agentB.name, { + destination_url: 'https://example.com/chat', + message: 'Join us!' + }); + + expect(invite).to.have.property('id'); + expect(invite.destination_url).to.equal('https://example.com/chat'); + expect(invite.message).to.equal('Join us!'); + }); + + it('should reject missing destination_url', async () => { + try { + await InviteService.create(agentA.id, agentB.name, {}); + expect.fail('Should have thrown error'); + } catch (error) { + expect(error.name).to.equal('BadRequestError'); + } + }); + + it('should reject non-existent agent', async () => { + try { + await InviteService.create(agentA.id, 'nonexistent_agent', { + destination_url: 'https://example.com' + }); + expect.fail('Should have thrown error'); + } catch (error) { + expect(error.name).to.equal('NotFoundError'); + } + }); + }); + + describe('InviteService.getPending()', () => { + it('should return pending invitations for agent', async () => { + await InviteService.create(agentA.id, agentB.name, { + destination_url: 'https://example.com/event', + message: 'Test invite' + }); + + const invites = await InviteService.getPending(agentB.id); + expect(invites).to.be.an('array'); + expect(invites.length).to.be.greaterThan(0); + expect(invites[0]).to.have.property('from_agent_name', agentA.name); + }); + + it('should not show invites for other agents', async () => { + const invites = await InviteService.getPending(agentA.id); + const hasInviteToB = invites.some(i => i.from_agent_name === agentA.name); + expect(hasInviteToB).to.be.false; + }); + }); + + describe('InviteService.accept()', () => { + it('should accept invitation', async () => { + const created = await InviteService.create(agentA.id, agentB.name, { + destination_url: 'https://example.com/accept-test' + }); + + const result = await InviteService.accept(created.id, agentB.id); + expect(result).to.have.property('destination_url'); + + const pending = await InviteService.getPending(agentB.id); + const found = pending.find(i => i.id === created.id); + expect(found).to.be.undefined; + }); + + it('should reject accepting someone else\'s invite', async () => { + const created = await InviteService.create(agentA.id, agentB.name, { + destination_url: 'https://example.com/test' + }); + + try { + await InviteService.accept(created.id, agentA.id); + expect.fail('Should have thrown error'); + } catch (error) { + expect(error.name).to.equal('NotFoundError'); + } + }); + }); + + describe('InviteService.decline()', () => { + it('should decline invitation', async () => { + const created = await InviteService.create(agentA.id, agentB.name, { + destination_url: 'https://example.com/decline-test' + }); + + await InviteService.decline(created.id, agentB.id); + + const pending = await InviteService.getPending(agentB.id); + const found = pending.find(i => i.id === created.id); + expect(found).to.be.undefined; + }); + }); + + describe('Security: Rate Limiting', () => { + it('should enforce rate limit of 10 invites per hour', async () => { + const testAgent = await AgentService.register({ + name: `ratelimit_test_${Date.now()}`, + description: 'Rate limit test' + }); + const sender = await AgentService.findByApiKey(testAgent.agent.api_key); + + // Send 10 invites (should succeed) + for (let i = 0; i < 10; i++) { + await InviteService.create(sender.id, agentB.name, { + destination_url: `https://example.com/test${i}` + }); + } + + // 11th invite should fail + try { + await InviteService.create(sender.id, agentB.name, { + destination_url: 'https://example.com/test11' + }); + expect.fail('Should have thrown rate limit error'); + } catch (error) { + expect(error.name).to.equal('BadRequestError'); + expect(error.message).to.include('Rate limit'); + } + }); + }); + + describe('Security: URL Validation', () => { + it('should reject non-http(s) URLs', async () => { + const maliciousUrls = [ + 'javascript:alert(1)', + 'data:text/html,', + 'file:///etc/passwd', + 'ftp://example.com' + ]; + + for (const url of maliciousUrls) { + try { + await InviteService.create(agentA.id, agentB.name, { + destination_url: url + }); + expect.fail(`Should have rejected URL: ${url}`); + } catch (error) { + expect(error.name).to.equal('BadRequestError'); + expect(error.message).to.include('Invalid destination URL'); + } + } + }); + + it('should accept valid http and https URLs', async () => { + const validUrls = [ + 'https://example.com/chat', + 'http://localhost:3000/test', + 'https://sub.domain.com/path?query=value' + ]; + + for (const url of validUrls) { + const invite = await InviteService.create(agentA.id, agentB.name, { + destination_url: url + }); + expect(invite.destination_url).to.equal(url); + } + }); + }); + + describe('Security: Message Sanitization', () => { + it('should cap message length at 500 characters', async () => { + const longMessage = 'a'.repeat(1000); + const invite = await InviteService.create(agentA.id, agentB.name, { + destination_url: 'https://example.com/test', + message: longMessage + }); + + expect(invite.message.length).to.equal(500); + }); + }); +});