refactor: Convert to RPC interface with single /rpc endpoint

BREAKING CHANGES:
- Replaced REST API with RPC interface
- Single POST /rpc endpoint for all operations
- Removed auth middleware (per-method auth instead)
- Support for batch operations
- Message format changed for all methods

Changes:
- Created src/rpc.ts with all method handlers
- Simplified src/app.ts to only handle /rpc endpoint
- Removed src/middleware/auth.ts
- Updated README.md with complete RPC documentation
This commit is contained in:
2025-12-12 19:51:58 +01:00
parent 4e73157a16
commit 17765a9f4f
4 changed files with 1175 additions and 971 deletions

View File

@@ -2,20 +2,14 @@ import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { Storage } from './storage/types.ts';
import { Config } from './config.ts';
import { createAuthMiddleware, getAuthenticatedUsername } from './middleware/auth.ts';
import { validateUsernameClaim, validateServicePublish, validateServiceFqn, parseServiceFqn, isVersionCompatible } from './crypto.ts';
import type { Context } from 'hono';
import { handleRpc, RpcRequest } from './rpc.ts';
/**
* Creates the Hono application with username and service-based WebRTC signaling
* RESTful API design - v0.11.0
* Creates the Hono application with RPC interface
*/
export function createApp(storage: Storage, config: Config) {
const app = new Hono();
// Create auth middleware
const authMiddleware = createAuthMiddleware(storage);
// Enable CORS
app.use('/*', cors({
origin: (origin) => {
@@ -27,622 +21,70 @@ export function createApp(storage: Storage, config: Config) {
}
return config.corsOrigins[0];
},
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Origin', 'Authorization'],
allowMethods: ['GET', 'POST', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Origin'],
exposeHeaders: ['Content-Type'],
maxAge: 600,
credentials: true,
credentials: false,
maxAge: 86400,
}));
// ===== General Endpoints =====
/**
* GET /
* Returns server information
*/
// Root endpoint - server info
app.get('/', (c) => {
return c.json({
version: config.version,
name: 'Rondevu',
description: 'DNS-like WebRTC signaling with username claiming and service discovery'
});
description: 'WebRTC signaling with RPC interface and Ed25519 authentication',
}, 200);
});
/**
* GET /health
* Health check endpoint
*/
// Health check
app.get('/health', (c) => {
return c.json({
status: 'ok',
timestamp: Date.now(),
version: config.version
});
});
// ===== User Management (RESTful) =====
/**
* GET /users/:username
* Check if username is available or get claim info
*/
app.get('/users/:username', async (c) => {
try {
const username = c.req.param('username');
const claimed = await storage.getUsername(username);
if (!claimed) {
return c.json({
username,
available: true
}, 200);
}
return c.json({
username: claimed.username,
available: false,
claimedAt: claimed.claimedAt,
expiresAt: claimed.expiresAt,
publicKey: claimed.publicKey
}, 200);
} catch (err) {
console.error('Error checking username:', err);
return c.json({ error: 'Internal server error' }, 500);
}
version: config.version,
}, 200);
});
/**
* POST /users/:username
* Claim a username with cryptographic proof
* POST /rpc
* RPC endpoint - accepts single or batch method calls
*/
app.post('/users/:username', async (c) => {
try {
const username = c.req.param('username');
const body = await c.req.json();
const { publicKey, signature, message } = body;
if (!publicKey || !signature || !message) {
return c.json({ error: 'Missing required parameters: publicKey, signature, message' }, 400);
}
// Validate claim
const validation = await validateUsernameClaim(username, publicKey, signature, message);
if (!validation.valid) {
return c.json({ error: validation.error }, 400);
}
// Attempt to claim username
try {
const claimed = await storage.claimUsername({
username,
publicKey,
signature,
message
});
return c.json({
username: claimed.username,
claimedAt: claimed.claimedAt,
expiresAt: claimed.expiresAt
}, 201);
} catch (err: any) {
if (err.message?.includes('already claimed')) {
return c.json({ error: 'Username already claimed by different public key' }, 409);
}
throw err;
}
} catch (err) {
console.error('Error claiming username:', err);
return c.json({ error: 'Internal server error' }, 500);
}
});
// ===== Service Discovery and Management =====
/**
* GET /services/:fqn
* Get service by FQN with optional discovery
* Supports three modes:
* 1. Direct lookup: /services/chat:1.0.0@alice - Returns specific user's offer
* 2. Random discovery: /services/chat:1.0.0 - Returns random available offer
* 3. Paginated discovery: /services/chat:1.0.0?limit=10&offset=0 - Returns array of available offers
*/
app.get('/services/:fqn', async (c) => {
try {
const serviceFqn = decodeURIComponent(c.req.param('fqn'));
const limit = c.req.query('limit');
const offset = c.req.query('offset');
// Parse the requested FQN
const parsed = parseServiceFqn(serviceFqn);
if (!parsed) {
return c.json({ error: 'Invalid service FQN format. Use service:version or service:version@username' }, 400);
}
const { serviceName, version, username } = parsed;
// Mode 1: Direct lookup with username
if (username) {
// Find service by exact FQN
const service = await storage.getServiceByFqn(serviceFqn);
if (!service) {
return c.json({ error: 'Service not found' }, 404);
}
// Get available offer from this service
const serviceOffers = await storage.getOffersForService(service.id);
const availableOffer = serviceOffers.find(offer => !offer.answererUsername);
if (!availableOffer) {
return c.json({
error: 'No available offers',
message: 'All offers from this service are currently in use.'
}, 503);
}
return c.json({
serviceId: service.id,
username: service.username,
serviceFqn: service.serviceFqn,
offerId: availableOffer.id,
sdp: availableOffer.sdp,
createdAt: service.createdAt,
expiresAt: service.expiresAt
}, 200);
}
// Mode 2 & 3: Discovery without username
if (limit || offset) {
// Paginated discovery
const limitNum = limit ? Math.min(parseInt(limit, 10), 100) : 10;
const offsetNum = offset ? parseInt(offset, 10) : 0;
const services = await storage.discoverServices(serviceName, version, limitNum, offsetNum);
if (services.length === 0) {
return c.json({
error: 'No services found',
message: `No available services found for ${serviceName}:${version}`
}, 404);
}
// Get available offers for each service
const servicesWithOffers = await Promise.all(
services.map(async (service) => {
const offers = await storage.getOffersForService(service.id);
const availableOffer = offers.find(offer => !offer.answererUsername);
return availableOffer ? {
serviceId: service.id,
username: service.username,
serviceFqn: service.serviceFqn,
offerId: availableOffer.id,
sdp: availableOffer.sdp,
createdAt: service.createdAt,
expiresAt: service.expiresAt
} : null;
})
);
const availableServices = servicesWithOffers.filter(s => s !== null);
return c.json({
services: availableServices,
count: availableServices.length,
limit: limitNum,
offset: offsetNum
}, 200);
} else {
// Random discovery
const service = await storage.getRandomService(serviceName, version);
if (!service) {
return c.json({
error: 'No services found',
message: `No available services found for ${serviceName}:${version}`
}, 404);
}
// Get available offer
const offers = await storage.getOffersForService(service.id);
const availableOffer = offers.find(offer => !offer.answererUsername);
if (!availableOffer) {
return c.json({
error: 'No available offers',
message: 'Service found but no available offers.'
}, 503);
}
return c.json({
serviceId: service.id,
username: service.username,
serviceFqn: service.serviceFqn,
offerId: availableOffer.id,
sdp: availableOffer.sdp,
createdAt: service.createdAt,
expiresAt: service.expiresAt
}, 200);
}
} catch (err) {
console.error('Error getting service:', err);
return c.json({ error: 'Internal server error' }, 500);
}
});
/**
* POST /services
* Publish a service with one or more offers
* Service FQN must include username: service:version@username
*/
app.post('/services', authMiddleware, async (c) => {
let serviceFqn: string | undefined;
let createdOffers: any[] = [];
app.post('/rpc', async (c) => {
try {
const body = await c.req.json();
serviceFqn = body.serviceFqn;
const { offers, ttl, signature, message } = body;
if (!serviceFqn || !offers || !Array.isArray(offers) || offers.length === 0) {
return c.json({ error: 'Missing required parameters: serviceFqn, offers (must be non-empty array)' }, 400);
// Support both single request and batch array
const requests: RpcRequest[] = Array.isArray(body) ? body : [body];
// Validate requests
if (requests.length === 0) {
return c.json({ error: 'Empty request array' }, 400);
}
// Validate and parse service FQN
const fqnValidation = validateServiceFqn(serviceFqn);
if (!fqnValidation.valid) {
return c.json({ error: fqnValidation.error }, 400);
if (requests.length > 100) {
return c.json({ error: 'Too many requests in batch (max 100)' }, 400);
}
const parsed = parseServiceFqn(serviceFqn);
if (!parsed || !parsed.username) {
return c.json({ error: 'Service FQN must include username (format: service:version@username)' }, 400);
}
// Handle RPC
const responses = await handleRpc(requests, storage, config);
const username = parsed.username;
// Verify username ownership (signature required)
if (!signature || !message) {
return c.json({ error: 'Missing signature or message for username verification' }, 400);
}
const usernameRecord = await storage.getUsername(username);
if (!usernameRecord) {
return c.json({ error: 'Username not claimed' }, 404);
}
// Verify signature matches username's public key
const signatureValidation = await validateServicePublish(username, serviceFqn, usernameRecord.publicKey, signature, message);
if (!signatureValidation.valid) {
return c.json({ error: 'Invalid signature for username' }, 403);
}
// Note: createService handles upsert behavior (deletes existing service if it exists)
// Validate all offers
for (const offer of offers) {
if (!offer.sdp || typeof offer.sdp !== 'string' || offer.sdp.length === 0) {
return c.json({ error: 'Invalid SDP in offers array' }, 400);
}
if (offer.sdp.length > 64 * 1024) {
return c.json({ error: 'SDP too large (max 64KB)' }, 400);
}
}
// Calculate expiry
const authenticatedUsername = getAuthenticatedUsername(c);
const offerTtl = Math.min(
Math.max(ttl || config.offerDefaultTtl, config.offerMinTtl),
config.offerMaxTtl
);
const expiresAt = Date.now() + offerTtl;
// Prepare offer requests
const offerRequests = offers.map(offer => ({
username: authenticatedUsername,
sdp: offer.sdp,
expiresAt
}));
// Create service with offers
const result = await storage.createService({
serviceFqn,
expiresAt,
offers: offerRequests
});
createdOffers = result.offers;
// Return full service details with all offers
return c.json({
serviceFqn: result.service.serviceFqn,
username: result.service.username,
serviceId: result.service.id,
offers: result.offers.map(o => ({
offerId: o.id,
sdp: o.sdp,
createdAt: o.createdAt,
expiresAt: o.expiresAt
})),
createdAt: result.service.createdAt,
expiresAt: result.service.expiresAt
}, 201);
// Return single response or array based on input
return c.json(Array.isArray(body) ? responses : responses[0], 200);
} catch (err) {
console.error('Error creating service:', err);
console.error('Error details:', {
message: (err as Error).message,
stack: (err as Error).stack,
serviceFqn,
offerIds: createdOffers.map(o => o.id)
});
console.error('RPC error:', err);
return c.json({
error: 'Internal server error',
details: (err as Error).message
}, 500);
success: false,
error: 'Invalid request format',
}, 400);
}
});
/**
* DELETE /services/:fqn
* Delete a service by FQN (must include username)
*/
app.delete('/services/:fqn', authMiddleware, async (c) => {
try {
const serviceFqn = decodeURIComponent(c.req.param('fqn'));
// Parse and validate FQN
const parsed = parseServiceFqn(serviceFqn);
if (!parsed || !parsed.username) {
return c.json({ error: 'Service FQN must include username (format: service:version@username)' }, 400);
}
const username = parsed.username;
// Find service by FQN
const service = await storage.getServiceByFqn(serviceFqn);
if (!service) {
return c.json({ error: 'Service not found' }, 404);
}
const deleted = await storage.deleteService(service.id, username);
if (!deleted) {
return c.json({ error: 'Service not found or not owned by this username' }, 404);
}
return c.json({ success: true }, 200);
} catch (err) {
console.error('Error deleting service:', err);
return c.json({ error: 'Internal server error' }, 500);
}
});
// ===== WebRTC Signaling (Offer-Specific) =====
/**
* POST /services/:fqn/offers/:offerId/answer
* Answer a specific offer from a service
*/
app.post('/services/:fqn/offers/:offerId/answer', authMiddleware, async (c) => {
try {
const serviceFqn = decodeURIComponent(c.req.param('fqn'));
const offerId = c.req.param('offerId');
const body = await c.req.json();
const { sdp } = body;
if (!sdp) {
return c.json({ error: 'Missing required parameter: sdp' }, 400);
}
if (typeof sdp !== 'string' || sdp.length === 0) {
return c.json({ error: 'Invalid SDP' }, 400);
}
if (sdp.length > 64 * 1024) {
return c.json({ error: 'SDP too large (max 64KB)' }, 400);
}
// Verify offer exists
const offer = await storage.getOfferById(offerId);
if (!offer) {
return c.json({ error: 'Offer not found' }, 404);
}
const answererUsername = getAuthenticatedUsername(c);
const result = await storage.answerOffer(offerId, answererUsername, sdp);
if (!result.success) {
return c.json({ error: result.error }, 400);
}
return c.json({
success: true,
offerId: offerId
}, 200);
} catch (err) {
console.error('Error answering offer:', err);
return c.json({ error: 'Internal server error' }, 500);
}
});
/**
* GET /services/:fqn/offers/:offerId/answer
* Get answer for a specific offer (offerer polls this)
*/
app.get('/services/:fqn/offers/:offerId/answer', authMiddleware, async (c) => {
try {
const serviceFqn = decodeURIComponent(c.req.param('fqn'));
const offerId = c.req.param('offerId');
const username = getAuthenticatedUsername(c);
// Get the offer
const offer = await storage.getOfferById(offerId);
if (!offer) {
return c.json({ error: 'Offer not found' }, 404);
}
// Verify ownership
if (offer.username !== username) {
return c.json({ error: 'Not authorized to access this offer' }, 403);
}
if (!offer.answerSdp) {
return c.json({ error: 'Offer not yet answered' }, 404);
}
return c.json({
offerId: offer.id,
answererId: offer.answererUsername,
sdp: offer.answerSdp,
answeredAt: offer.answeredAt
}, 200);
} catch (err) {
console.error('Error getting offer answer:', err);
return c.json({ error: 'Internal server error' }, 500);
}
});
/**
* GET /poll
* Combined efficient polling endpoint for answers and ICE candidates
* Returns all answered offers and ICE candidates for all peer's offers since timestamp
*/
app.get('/poll', authMiddleware, async (c) => {
try {
const username = getAuthenticatedUsername(c);
const since = c.req.query('since');
const sinceTimestamp = since ? parseInt(since, 10) : 0;
// Get all answered offers
const answeredOffers = await storage.getAnsweredOffers(username);
const filteredAnswers = since
? answeredOffers.filter(offer => offer.answeredAt && offer.answeredAt > sinceTimestamp)
: answeredOffers;
// Get all user's offers
const allOffers = await storage.getOffersByUsername(username);
// For each offer, get ICE candidates from both sides
const iceCandidatesByOffer: Record<string, any[]> = {};
for (const offer of allOffers) {
const allCandidates = [];
// Get offerer ICE candidates (answerer polls for these, offerer can also see for debugging/sync)
const offererCandidates = await storage.getIceCandidates(offer.id, 'offerer', sinceTimestamp);
for (const c of offererCandidates) {
allCandidates.push({
candidate: c.candidate,
role: 'offerer',
username: c.username,
createdAt: c.createdAt
});
}
// Get answerer ICE candidates (offerer polls for these)
const answererCandidates = await storage.getIceCandidates(offer.id, 'answerer', sinceTimestamp);
for (const c of answererCandidates) {
allCandidates.push({
candidate: c.candidate,
role: 'answerer',
username: c.username,
createdAt: c.createdAt
});
}
if (allCandidates.length > 0) {
iceCandidatesByOffer[offer.id] = allCandidates;
}
}
return c.json({
answers: filteredAnswers.map(offer => ({
offerId: offer.id,
serviceId: offer.serviceId,
answererId: offer.answererUsername,
sdp: offer.answerSdp,
answeredAt: offer.answeredAt
})),
iceCandidates: iceCandidatesByOffer
}, 200);
} catch (err) {
console.error('Error polling offers:', err);
return c.json({ error: 'Internal server error' }, 500);
}
});
/**
* POST /services/:fqn/offers/:offerId/ice-candidates
* Add ICE candidates for a specific offer
*/
app.post('/services/:fqn/offers/:offerId/ice-candidates', authMiddleware, async (c) => {
try {
const serviceFqn = decodeURIComponent(c.req.param('fqn'));
const offerId = c.req.param('offerId');
const body = await c.req.json();
const { candidates } = body;
if (!Array.isArray(candidates) || candidates.length === 0) {
return c.json({ error: 'Missing or invalid required parameter: candidates' }, 400);
}
const username = getAuthenticatedUsername(c);
// Get offer to determine role
const offer = await storage.getOfferById(offerId);
if (!offer) {
return c.json({ error: 'Offer not found' }, 404);
}
// Determine role (offerer or answerer)
const role = offer.username === username ? 'offerer' : 'answerer';
const count = await storage.addIceCandidates(offerId, username, role, candidates);
return c.json({ count, offerId }, 200);
} catch (err) {
console.error('Error adding ICE candidates:', err);
return c.json({ error: 'Internal server error' }, 500);
}
});
/**
* GET /services/:fqn/offers/:offerId/ice-candidates
* Get ICE candidates for a specific offer
*/
app.get('/services/:fqn/offers/:offerId/ice-candidates', authMiddleware, async (c) => {
try {
const serviceFqn = decodeURIComponent(c.req.param('fqn'));
const offerId = c.req.param('offerId');
const since = c.req.query('since');
const username = getAuthenticatedUsername(c);
// Get offer to determine role
const offer = await storage.getOfferById(offerId);
if (!offer) {
return c.json({ error: 'Offer not found' }, 404);
}
// Get candidates for opposite role
const targetRole = offer.username === username ? 'answerer' : 'offerer';
const sinceTimestamp = since ? parseInt(since, 10) : undefined;
const candidates = await storage.getIceCandidates(offerId, targetRole, sinceTimestamp);
return c.json({
candidates: candidates.map(c => ({
candidate: c.candidate,
createdAt: c.createdAt
})),
offerId
}, 200);
} catch (err) {
console.error('Error getting ICE candidates:', err);
return c.json({ error: 'Internal server error' }, 500);
}
// 404 for all other routes
app.all('*', (c) => {
return c.json({
error: 'Not found. Use POST /rpc for all API calls.',
}, 404);
});
return app;

View File

@@ -1,84 +0,0 @@
import { Context, Next } from 'hono';
import { verifyEd25519Signature, validateAuthMessage } from '../crypto.ts';
import { Storage } from '../storage/types.ts';
/**
* Authentication middleware for Rondevu - Ed25519 signature-based
* Verifies username ownership via Ed25519 signatures
*
* For POST requests: Extracts username, signature, message from request body
* For GET requests: Extracts username, signature, message from query params
*/
export function createAuthMiddleware(storage: Storage) {
return async (c: Context, next: Next) => {
let username: string | undefined;
let signature: string | undefined;
let message: string | undefined;
// Determine if this is a GET or POST request
if (c.req.method === 'GET') {
// Extract from query params
const query = c.req.query();
username = query.username;
signature = query.signature;
message = query.message;
} else {
// Extract from request body
try {
const body = await c.req.json();
username = body.username;
signature = body.signature;
message = body.message;
} catch (err) {
return c.json({ error: 'Invalid JSON body' }, 400);
}
}
// Validate presence of auth fields
if (!username || !signature || !message) {
return c.json({
error: 'Missing authentication fields: username, signature, and message are required'
}, 401);
}
// Get username record to fetch public key
const usernameRecord = await storage.getUsername(username);
if (!usernameRecord) {
return c.json({
error: `Username "${username}" is not claimed. Please claim username first.`
}, 401);
}
// Verify Ed25519 signature
const isValid = await verifyEd25519Signature(
usernameRecord.publicKey,
signature,
message
);
if (!isValid) {
return c.json({ error: 'Invalid signature' }, 401);
}
// Validate message format and timestamp
const validation = validateAuthMessage(username, message);
if (!validation.valid) {
return c.json({ error: validation.error }, 401);
}
// Store authenticated username in context
c.set('username', username);
await next();
};
}
/**
* Helper to get authenticated username from context
*/
export function getAuthenticatedUsername(c: Context): string {
const username = c.get('username');
if (!username) {
throw new Error('No authenticated username in context');
}
return username;
}

721
src/rpc.ts Normal file
View File

@@ -0,0 +1,721 @@
import { Context } from 'hono';
import { Storage } from './storage/types.ts';
import { Config } from './config.ts';
import {
validateUsernameClaim,
validateServicePublish,
validateServiceFqn,
parseServiceFqn,
isVersionCompatible,
verifyEd25519Signature,
validateAuthMessage,
} from './crypto.ts';
/**
* RPC request format
*/
export interface RpcRequest {
method: string;
message: string;
signature: string;
params?: any;
}
/**
* RPC response format
*/
export interface RpcResponse {
success: boolean;
result?: any;
error?: string;
}
/**
* RPC method handler
*/
type RpcHandler = (
params: any,
message: string,
signature: string,
storage: Storage,
config: Config
) => Promise<any>;
/**
* Verify authentication for a method call
*/
async function verifyAuth(
username: string,
message: string,
signature: string,
storage: Storage
): Promise<{ valid: boolean; error?: string }> {
// Get username record to fetch public key
const usernameRecord = await storage.getUsername(username);
if (!usernameRecord) {
return {
valid: false,
error: `Username "${username}" is not claimed. Please claim username first.`,
};
}
// Verify Ed25519 signature
const isValid = await verifyEd25519Signature(
usernameRecord.publicKey,
signature,
message
);
if (!isValid) {
return { valid: false, error: 'Invalid signature' };
}
// Validate message format and timestamp
const validation = validateAuthMessage(username, message);
if (!validation.valid) {
return { valid: false, error: validation.error };
}
return { valid: true };
}
/**
* Extract username from message
*/
function extractUsername(message: string): string | null {
// Message format: method:username:...
const parts = message.split(':');
if (parts.length < 2) return null;
return parts[1];
}
/**
* RPC Method Handlers
*/
const handlers: Record<string, RpcHandler> = {
/**
* Check if username is available
*/
async getUser(params, message, signature, storage, config) {
const { username } = params;
const claimed = await storage.getUsername(username);
if (!claimed) {
return {
username,
available: true,
};
}
return {
username: claimed.username,
available: false,
claimedAt: claimed.claimedAt,
expiresAt: claimed.expiresAt,
publicKey: claimed.publicKey,
};
},
/**
* Claim a username
*/
async claimUsername(params, message, signature, storage, config) {
const { username, publicKey } = params;
// Validate claim
const validation = await validateUsernameClaim({
username,
publicKey,
signature,
message,
});
if (!validation.valid) {
throw new Error(validation.error || 'Invalid username claim');
}
// Claim the username
const expiresAt = Date.now() + 365 * 24 * 60 * 60 * 1000; // 365 days
await storage.claimUsername({
username,
publicKey,
expiresAt,
});
return { success: true, username };
},
/**
* Get service by FQN
*/
async getService(params, message, signature, storage, config) {
const { serviceFqn, limit, offset } = params;
const username = extractUsername(message);
// Verify authentication
if (username) {
const auth = await verifyAuth(username, message, signature, storage);
if (!auth.valid) {
throw new Error(auth.error);
}
}
// Parse and validate FQN
const fqnValidation = validateServiceFqn(serviceFqn);
if (!fqnValidation.valid) {
throw new Error(fqnValidation.error || 'Invalid service FQN');
}
const parsed = parseServiceFqn(serviceFqn);
if (!parsed) {
throw new Error('Failed to parse service FQN');
}
// Paginated discovery mode
if (limit !== undefined) {
const pageLimit = Math.min(Math.max(1, limit), 100);
const pageOffset = Math.max(0, offset || 0);
const allServices = await storage.getServicesByName(
parsed.service,
parsed.version
);
const compatibleServices = allServices.filter((s) => {
const serviceVersion = parseServiceFqn(s.serviceFqn);
return (
serviceVersion &&
isVersionCompatible(parsed.version, serviceVersion.version)
);
});
const usernameSet = new Set<string>();
const uniqueServices: any[] = [];
for (const service of compatibleServices) {
if (!usernameSet.has(service.username)) {
usernameSet.add(service.username);
const offers = await storage.getOffersByService(service.id);
const availableOffer = offers.find((o) => !o.answererUsername);
if (availableOffer) {
uniqueServices.push({
serviceId: service.id,
username: service.username,
serviceFqn: service.serviceFqn,
offerId: availableOffer.id,
sdp: availableOffer.sdp,
createdAt: service.createdAt,
expiresAt: service.expiresAt,
});
}
}
}
const paginatedServices = uniqueServices.slice(
pageOffset,
pageOffset + pageLimit
);
return {
services: paginatedServices,
count: paginatedServices.length,
limit: pageLimit,
offset: pageOffset,
};
}
// Direct lookup with username
if (parsed.username) {
const service = await storage.getServiceByFqn(serviceFqn);
if (!service) {
throw new Error('Service not found');
}
const offers = await storage.getOffersByService(service.id);
const availableOffer = offers.find((o) => !o.answererUsername);
if (!availableOffer) {
throw new Error('Service has no available offers');
}
return {
serviceId: service.id,
username: service.username,
serviceFqn: service.serviceFqn,
offerId: availableOffer.id,
sdp: availableOffer.sdp,
createdAt: service.createdAt,
expiresAt: service.expiresAt,
};
}
// Random discovery without username
const allServices = await storage.getServicesByName(
parsed.service,
parsed.version
);
const compatibleServices = allServices.filter((s) => {
const serviceVersion = parseServiceFqn(s.serviceFqn);
return (
serviceVersion &&
isVersionCompatible(parsed.version, serviceVersion.version)
);
});
if (compatibleServices.length === 0) {
throw new Error('No services found');
}
const randomService =
compatibleServices[
Math.floor(Math.random() * compatibleServices.length)
];
const offers = await storage.getOffersByService(randomService.id);
const availableOffer = offers.find((o) => !o.answererUsername);
if (!availableOffer) {
throw new Error('Service has no available offers');
}
return {
serviceId: randomService.id,
username: randomService.username,
serviceFqn: randomService.serviceFqn,
offerId: availableOffer.id,
sdp: availableOffer.sdp,
createdAt: randomService.createdAt,
expiresAt: randomService.expiresAt,
};
},
/**
* Publish a service
*/
async publishService(params, message, signature, storage, config) {
const { serviceFqn, offers, ttl } = params;
const username = extractUsername(message);
if (!username) {
throw new Error('Username required for service publishing');
}
// Verify authentication
const auth = await verifyAuth(username, message, signature, storage);
if (!auth.valid) {
throw new Error(auth.error);
}
// Validate service FQN
const fqnValidation = validateServiceFqn(serviceFqn);
if (!fqnValidation.valid) {
throw new Error(fqnValidation.error || 'Invalid service FQN');
}
const parsed = parseServiceFqn(serviceFqn);
if (!parsed || !parsed.username) {
throw new Error('Service FQN must include username');
}
if (parsed.username !== username) {
throw new Error('Service FQN username must match authenticated username');
}
// Validate offers
if (!offers || !Array.isArray(offers) || offers.length === 0) {
throw new Error('Must provide at least one offer');
}
if (offers.length > config.maxOffersPerRequest) {
throw new Error(
`Too many offers (max ${config.maxOffersPerRequest})`
);
}
// Create service
const now = Date.now();
const offerTtl =
ttl !== undefined
? Math.min(
Math.max(ttl, config.offerMinTtl),
config.offerMaxTtl
)
: config.offerDefaultTtl;
const expiresAt = now + offerTtl;
const service = await storage.createService({
username,
serviceFqn,
serviceName: parsed.service,
version: parsed.version,
expiresAt,
});
// Create offers
const createdOffers = [];
for (const offer of offers) {
const createdOffer = await storage.createOffer({
username,
serviceId: service.id,
serviceFqn,
sdp: offer.sdp,
ttl: offerTtl,
});
createdOffers.push({
offerId: createdOffer.id,
sdp: createdOffer.sdp,
createdAt: createdOffer.createdAt,
expiresAt: createdOffer.expiresAt,
});
}
return {
serviceId: service.id,
username: service.username,
serviceFqn: service.serviceFqn,
offers: createdOffers,
createdAt: service.createdAt,
expiresAt: service.expiresAt,
};
},
/**
* Delete a service
*/
async deleteService(params, message, signature, storage, config) {
const { serviceFqn } = params;
const username = extractUsername(message);
if (!username) {
throw new Error('Username required');
}
// Verify authentication
const auth = await verifyAuth(username, message, signature, storage);
if (!auth.valid) {
throw new Error(auth.error);
}
const parsed = parseServiceFqn(serviceFqn);
if (!parsed || !parsed.username) {
throw new Error('Service FQN must include username');
}
const service = await storage.getServiceByFqn(serviceFqn);
if (!service) {
throw new Error('Service not found');
}
const deleted = await storage.deleteService(service.id, username);
if (!deleted) {
throw new Error('Service not found or not owned by this username');
}
return { success: true };
},
/**
* Answer an offer
*/
async answerOffer(params, message, signature, storage, config) {
const { serviceFqn, offerId, sdp } = params;
const username = extractUsername(message);
if (!username) {
throw new Error('Username required');
}
// Verify authentication
const auth = await verifyAuth(username, message, signature, storage);
if (!auth.valid) {
throw new Error(auth.error);
}
if (!sdp || typeof sdp !== 'string' || sdp.length === 0) {
throw new Error('Invalid SDP');
}
if (sdp.length > 64 * 1024) {
throw new Error('SDP too large (max 64KB)');
}
const offer = await storage.getOfferById(offerId);
if (!offer) {
throw new Error('Offer not found');
}
if (offer.answererUsername) {
throw new Error('Offer already answered');
}
await storage.answerOffer(offerId, username, sdp);
return { success: true, offerId };
},
/**
* Get answer for an offer
*/
async getOfferAnswer(params, message, signature, storage, config) {
const { serviceFqn, offerId } = params;
const username = extractUsername(message);
if (!username) {
throw new Error('Username required');
}
// Verify authentication
const auth = await verifyAuth(username, message, signature, storage);
if (!auth.valid) {
throw new Error(auth.error);
}
const offer = await storage.getOfferById(offerId);
if (!offer) {
throw new Error('Offer not found');
}
if (offer.username !== username) {
throw new Error('Not authorized to access this offer');
}
if (!offer.answererUsername || !offer.answerSdp) {
throw new Error('Offer not yet answered');
}
return {
sdp: offer.answerSdp,
offerId: offer.id,
answererId: offer.answererUsername,
answeredAt: offer.answeredAt,
};
},
/**
* Combined polling for answers and ICE candidates
*/
async poll(params, message, signature, storage, config) {
const { since } = params;
const username = extractUsername(message);
if (!username) {
throw new Error('Username required');
}
// Verify authentication
const auth = await verifyAuth(username, message, signature, storage);
if (!auth.valid) {
throw new Error(auth.error);
}
const sinceTimestamp = since || 0;
// Get all answered offers
const answeredOffers = await storage.getAnsweredOffers(username);
const filteredAnswers = answeredOffers.filter(
(offer) => offer.answeredAt && offer.answeredAt > sinceTimestamp
);
// Get all user's offers
const allOffers = await storage.getOffersByUsername(username);
// For each offer, get ICE candidates from both sides
const iceCandidatesByOffer: Record<string, any[]> = {};
for (const offer of allOffers) {
const offererCandidates = await storage.getIceCandidates(
offer.id,
'offerer',
sinceTimestamp
);
const answererCandidates = await storage.getIceCandidates(
offer.id,
'answerer',
sinceTimestamp
);
const allCandidates = [
...offererCandidates.map((c: any) => ({
...c,
role: 'offerer' as const,
})),
...answererCandidates.map((c: any) => ({
...c,
role: 'answerer' as const,
})),
];
if (allCandidates.length > 0) {
const isOfferer = offer.username === username;
const filtered = allCandidates.filter((c) =>
isOfferer ? c.role === 'answerer' : c.role === 'offerer'
);
if (filtered.length > 0) {
iceCandidatesByOffer[offer.id] = filtered;
}
}
}
return {
answers: filteredAnswers.map((offer) => ({
offerId: offer.id,
serviceId: offer.serviceId,
answererId: offer.answererUsername,
sdp: offer.answerSdp,
answeredAt: offer.answeredAt,
})),
iceCandidates: iceCandidatesByOffer,
};
},
/**
* Add ICE candidates
*/
async addIceCandidates(params, message, signature, storage, config) {
const { serviceFqn, offerId, candidates } = params;
const username = extractUsername(message);
if (!username) {
throw new Error('Username required');
}
// Verify authentication
const auth = await verifyAuth(username, message, signature, storage);
if (!auth.valid) {
throw new Error(auth.error);
}
if (!Array.isArray(candidates) || candidates.length === 0) {
throw new Error('Missing or invalid required parameter: candidates');
}
const offer = await storage.getOfferById(offerId);
if (!offer) {
throw new Error('Offer not found');
}
const role = offer.username === username ? 'offerer' : 'answerer';
const count = await storage.addIceCandidates(
offerId,
username,
role,
candidates
);
return { count, offerId };
},
/**
* Get ICE candidates
*/
async getIceCandidates(params, message, signature, storage, config) {
const { serviceFqn, offerId, since } = params;
const username = extractUsername(message);
if (!username) {
throw new Error('Username required');
}
// Verify authentication
const auth = await verifyAuth(username, message, signature, storage);
if (!auth.valid) {
throw new Error(auth.error);
}
const sinceTimestamp = since || 0;
const offer = await storage.getOfferById(offerId);
if (!offer) {
throw new Error('Offer not found');
}
const isOfferer = offer.username === username;
const role = isOfferer ? 'answerer' : 'offerer';
const candidates = await storage.getIceCandidates(
offerId,
role,
sinceTimestamp
);
return {
candidates: candidates.map((c: any) => ({
candidate: c.candidate,
createdAt: c.createdAt,
})),
offerId,
};
},
};
/**
* Handle RPC batch request
*/
export async function handleRpc(
requests: RpcRequest[],
storage: Storage,
config: Config
): Promise<RpcResponse[]> {
const responses: RpcResponse[] = [];
for (const request of requests) {
try {
const { method, message, signature, params } = request;
// Validate request
if (!method || typeof method !== 'string') {
responses.push({
success: false,
error: 'Missing or invalid method',
});
continue;
}
if (!message || typeof message !== 'string') {
responses.push({
success: false,
error: 'Missing or invalid message',
});
}
if (!signature || typeof signature !== 'string') {
responses.push({
success: false,
error: 'Missing or invalid signature',
});
continue;
}
// Get handler
const handler = handlers[method];
if (!handler) {
responses.push({
success: false,
error: `Unknown method: ${method}`,
});
continue;
}
// Execute handler
const result = await handler(
params || {},
message,
signature,
storage,
config
);
responses.push({
success: true,
result,
});
} catch (err) {
responses.push({
success: false,
error: (err as Error).message || 'Internal server error',
});
}
}
return responses;
}