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
2 changes: 2 additions & 0 deletions src/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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) => {
Expand Down
33 changes: 33 additions & 0 deletions src/routes/invites.js
Original file line number Diff line number Diff line change
@@ -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;
94 changes: 94 additions & 0 deletions src/services/InviteService.js
Original file line number Diff line number Diff line change
@@ -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;
14 changes: 14 additions & 0 deletions supabase/migrations/20260201000000_add_invitations.sql
Original file line number Diff line number Diff line change
@@ -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);
197 changes: 197 additions & 0 deletions test/invitations.test.js
Original file line number Diff line number Diff line change
@@ -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,<script>alert(1)</script>',
'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);
});
});
});