diff --git a/README.md b/README.md index 6eda279..bf6ab03 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ [![npm version](https://img.shields.io/npm/v/@xtr-dev/rondevu-server)](https://www.npmjs.com/package/@xtr-dev/rondevu-server) -🌐 **Simple WebRTC signaling with username-based discovery** +🌐 **Simple WebRTC signaling with RPC interface** -Scalable WebRTC signaling server with cryptographic username claiming, service publishing with semantic versioning, and efficient offer/answer exchange. +Scalable WebRTC signaling server with cryptographic username claiming, service publishing with semantic versioning, and efficient offer/answer exchange via JSON-RPC interface. **Related repositories:** - [@xtr-dev/rondevu-client](https://github.com/xtr-dev/rondevu-client) - TypeScript client library ([npm](https://www.npmjs.com/package/@xtr-dev/rondevu-client)) @@ -15,14 +15,14 @@ Scalable WebRTC signaling server with cryptographic username claiming, service p ## Features +- **RPC Interface**: Single endpoint for all operations with batching support - **Username Claiming**: Cryptographic username ownership with Ed25519 signatures (365-day validity, auto-renewed on use) -- **Anonymous Users**: Support for `anon-*` usernames for quick testing - **Service Publishing**: Service:version@username naming (e.g., `chat:1.0.0@alice`) - **Service Discovery**: Random and paginated discovery for finding services without knowing usernames - **Semantic Versioning**: Compatible version matching (chat:1.0.0 matches any 1.x.x) - **Signature-Based Authentication**: All authenticated requests use Ed25519 signatures - **Complete WebRTC Signaling**: Offer/answer exchange and ICE candidate relay -- **Efficient Batch Polling**: Combined endpoint for answers and ICE candidates (50% fewer HTTP requests) +- **Batch Operations**: Execute multiple operations in a single HTTP request - **Dual Storage**: SQLite (Node.js/Docker) and Cloudflare D1 (Workers) backends ## Architecture @@ -58,67 +58,136 @@ docker build -t rondevu . && docker run -p 3000:3000 -e STORAGE_PATH=:memory: ro npx wrangler deploy ``` -## API Endpoints +## RPC Interface -### Public Endpoints +All API calls are made to `POST /rpc` with JSON-RPC format. -#### `GET /` -Returns server version and info +### Request Format + +**Single method call:** +```json +{ + "method": "getUser", + "message": "getUser:alice:1733404800000", + "signature": "base64-encoded-signature", + "params": { + "username": "alice" + } +} +``` + +**Batch calls:** +```json +[ + { + "method": "getUser", + "message": "getUser:alice:1733404800000", + "signature": "base64-encoded-signature", + "params": { "username": "alice" } + }, + { + "method": "claimUsername", + "message": "claim:bob:1733404800000", + "signature": "base64-encoded-signature", + "params": { + "username": "bob", + "publicKey": "base64-encoded-public-key" + } + } +] +``` + +### Response Format + +**Single response:** +```json +{ + "success": true, + "result": { + "username": "alice", + "available": false, + "claimedAt": 1733404800000, + "expiresAt": 1765027200000, + "publicKey": "base64-encoded-public-key" + } +} +``` + +**Batch responses:** +```json +[ + { + "success": true, + "result": { "username": "alice", "available": false } + }, + { + "success": true, + "result": { "success": true, "username": "bob" } + } +] +``` + +**Error response:** +```json +{ + "success": false, + "error": "Username already claimed by different public key" +} +``` + +## RPC Methods + +### `getUser` +Check username availability + +**Parameters:** +- `username` - Username to check + +**Message format:** `getUser:{username}:{timestamp}` (no authentication required) + +**Example:** +```json +{ + "method": "getUser", + "message": "getUser:alice:1733404800000", + "signature": "base64-signature", + "params": { "username": "alice" } +} +``` **Response:** ```json { - "version": "0.4.0", - "name": "Rondevu", - "description": "DNS-like WebRTC signaling with username claiming and service discovery" + "success": true, + "result": { + "username": "alice", + "available": false, + "claimedAt": 1733404800000, + "expiresAt": 1765027200000, + "publicKey": "base64-encoded-public-key" + } } ``` -#### `GET /health` -Health check endpoint with version - -**Response:** -```json -{ - "status": "ok", - "timestamp": 1733404800000, - "version": "0.4.0" -} -``` - -### User Management - -#### `GET /users/:username` -Check username availability and claim status - -**Response (Available):** -```json -{ - "username": "alice", - "available": true -} -``` - -**Response (Claimed):** -```json -{ - "username": "alice", - "available": false, - "claimedAt": 1733404800000, - "expiresAt": 1765027200000, - "publicKey": "base64-encoded-ed25519-public-key" -} -``` - -#### `POST /users/:username` +### `claimUsername` Claim a username with cryptographic proof -**Request:** +**Parameters:** +- `username` - Username to claim +- `publicKey` - Base64-encoded Ed25519 public key + +**Message format:** `claim:{username}:{timestamp}` + +**Example:** ```json { - "publicKey": "base64-encoded-ed25519-public-key", - "signature": "base64-encoded-signature", - "message": "claim:alice:1733404800000" + "method": "claimUsername", + "message": "claim:alice:1733404800000", + "signature": "base64-signature", + "params": { + "username": "alice", + "publicKey": "base64-encoded-public-key" + } } ``` @@ -126,158 +195,37 @@ Claim a username with cryptographic proof ```json { "success": true, - "username": "alice" + "result": { + "success": true, + "username": "alice" + } } ``` -**Validation:** -- Username format: `^[a-z0-9][a-z0-9-]*[a-z0-9]$` (3-32 characters) -- Signature must be valid Ed25519 signature -- Timestamp must be within 5 minutes (replay protection) -- Expires after 365 days, auto-renewed on use +### `getService` +Get service by FQN (direct lookup, random discovery, or paginated) -### Service Management +**Parameters:** +- `serviceFqn` - Service FQN (e.g., `chat:1.0.0` or `chat:1.0.0@alice`) +- `limit` - (optional) Number of results for paginated mode +- `offset` - (optional) Offset for paginated mode -#### `POST /services` -Publish a service with offers (requires username and signature) +**Message format:** `getService:{username}:{serviceFqn}:{timestamp}` -**Request:** +**Modes:** +1. **Direct lookup** (with @username): Returns specific user's service +2. **Random** (without @username, no limit): Returns random service +3. **Paginated** (without @username, with limit): Returns multiple services + +**Example:** ```json { - "username": "alice", - "serviceFqn": "chat:1.0.0@alice", - "offers": [ - { "sdp": "v=0..." }, - { "sdp": "v=0..." } - ], - "ttl": 300000, - "signature": "base64-encoded-signature", - "message": "publish:alice:chat:1.0.0@alice:1733404800000" -} -``` - -**Response:** -```json -{ - "serviceId": "uuid-v4", - "username": "alice", - "serviceFqn": "chat:1.0.0@alice", - "offers": [ - { - "offerId": "offer-hash-1", - "sdp": "v=0...", - "createdAt": 1733404800000, - "expiresAt": 1733405100000 - } - ], - "createdAt": 1733404800000, - "expiresAt": 1733405100000 -} -``` - -**Service FQN Format:** -- Format: `service:version@username` -- Service name: Lowercase alphanumeric + dash (e.g., `chat`, `video-call`) -- Version: Semantic versioning (e.g., `1.0.0`, `2.1.3`) -- Username: Claimed username -- Example: `chat:1.0.0@alice` - -**Validation:** -- Service name pattern: `^[a-z0-9][a-z0-9-]*[a-z0-9]$` -- Version pattern: `^[0-9]+\.[0-9]+\.[0-9]+$` -- Must include @username - -#### `GET /services/:fqn` -Get service by FQN - Three modes: - -**1. Direct Lookup (with username):** -``` -GET /services/chat:1.0.0@alice -``` -Returns first available offer from Alice's chat:1.0.0 service. - -**2. Random Discovery (without username):** -``` -GET /services/chat:1.0.0 -``` -Returns a random available offer from any user's chat:1.0.0 service. - -**3. Paginated Discovery (with query params):** -``` -GET /services/chat:1.0.0?limit=10&offset=0 -``` -Returns array of unique available offers from different users. - -**Semver Matching:** -- Requesting `chat:1.0.0` matches any `1.x.x` version -- Major version must match exactly (`chat:1.0.0` will NOT match `chat:2.0.0`) -- For major version 0, minor must also match (`0.1.0` will NOT match `0.2.0`) -- Returns the most recently published compatible version - -**Response (Single Offer):** -```json -{ - "serviceId": "uuid", - "username": "alice", - "serviceFqn": "chat:1.0.0@alice", - "offerId": "offer-hash", - "sdp": "v=0...", - "createdAt": 1733404800000, - "expiresAt": 1733405100000 -} -``` - -**Response (Paginated):** -```json -{ - "services": [ - { - "serviceId": "uuid", - "username": "alice", - "serviceFqn": "chat:1.0.0@alice", - "offerId": "offer-hash", - "sdp": "v=0...", - "createdAt": 1733404800000, - "expiresAt": 1733405100000 - } - ], - "count": 1, - "limit": 10, - "offset": 0 -} -``` - -#### `DELETE /services/:fqn` -Unpublish a service (requires username, signature, and ownership) - -**Request:** -```json -{ - "username": "alice", - "signature": "base64-encoded-signature", - "message": "deleteService:alice:chat:1.0.0@alice:1733404800000" -} -``` - -**Response:** -```json -{ - "success": true -} -``` - -### WebRTC Signaling - -#### `POST /services/:fqn/offers/:offerId/answer` -Post answer SDP to specific offer - -**Request:** -```json -{ - "username": "bob", - "sdp": "v=0...", - "signature": "base64-encoded-signature", - "message": "answerOffer:{username}:{offerId}:{timestamp}" + "method": "getService", + "message": "getService:bob:chat:1.0.0:1733404800000", + "signature": "base64-signature", + "params": { + "serviceFqn": "chat:1.0.0@alice" + } } ``` @@ -285,119 +233,298 @@ Post answer SDP to specific offer ```json { "success": true, - "offerId": "offer-hash" + "result": { + "serviceId": "uuid", + "username": "alice", + "serviceFqn": "chat:1.0.0@alice", + "offerId": "offer-hash", + "sdp": "v=0...", + "createdAt": 1733404800000, + "expiresAt": 1733405100000 + } } ``` -#### `GET /services/:fqn/offers/:offerId/answer` -Get answer SDP (offerer polls this) +### `publishService` +Publish a service with offers -**Query Parameters:** -- `username` - Your username -- `signature` - Base64-encoded Ed25519 signature -- `message` - Signed message (format: `getAnswer:{username}:{offerId}:{timestamp}`) +**Parameters:** +- `serviceFqn` - Service FQN with username (e.g., `chat:1.0.0@alice`) +- `offers` - Array of offers, each with `sdp` field +- `ttl` - (optional) Time to live in milliseconds + +**Message format:** `publishService:{username}:{serviceFqn}:{timestamp}` + +**Example:** +```json +{ + "method": "publishService", + "message": "publishService:alice:chat:1.0.0@alice:1733404800000", + "signature": "base64-signature", + "params": { + "serviceFqn": "chat:1.0.0@alice", + "offers": [ + { "sdp": "v=0..." }, + { "sdp": "v=0..." } + ], + "ttl": 300000 + } +} +``` **Response:** ```json { - "sdp": "v=0...", - "offerId": "offer-hash", - "answererUsername": "bob", - "answeredAt": 1733404800000 -} -``` - -Returns 404 if not yet answered. - -#### `GET /poll` -Combined polling endpoint for answers and ICE candidates - -**Query Parameters:** -- `username` - Your username -- `signature` - Base64-encoded Ed25519 signature -- `message` - Signed message (format: `poll:{username}:{timestamp}`) -- `since` - Optional timestamp to get only new data - -**Response:** -```json -{ - "answers": [ - { - "offerId": "offer-hash", - "serviceId": "service-uuid", - "answererUsername": "bob", - "sdp": "v=0...", - "answeredAt": 1733404800000 - } - ], - "iceCandidates": { - "offer-hash": [ + "success": true, + "result": { + "serviceId": "uuid", + "username": "alice", + "serviceFqn": "chat:1.0.0@alice", + "offers": [ { - "candidate": { "candidate": "...", "sdpMid": "0", "sdpMLineIndex": 0 }, - "role": "answerer", - "username": "bob", - "createdAt": 1733404800000 + "offerId": "offer-hash-1", + "sdp": "v=0...", + "createdAt": 1733404800000, + "expiresAt": 1733405100000 + } + ], + "createdAt": 1733404800000, + "expiresAt": 1733405100000 + } +} +``` + +### `deleteService` +Delete a service + +**Parameters:** +- `serviceFqn` - Service FQN with username + +**Message format:** `deleteService:{username}:{serviceFqn}:{timestamp}` + +**Example:** +```json +{ + "method": "deleteService", + "message": "deleteService:alice:chat:1.0.0@alice:1733404800000", + "signature": "base64-signature", + "params": { + "serviceFqn": "chat:1.0.0@alice" + } +} +``` + +**Response:** +```json +{ + "success": true, + "result": { "success": true } +} +``` + +### `answerOffer` +Answer a specific offer + +**Parameters:** +- `serviceFqn` - Service FQN +- `offerId` - Offer ID +- `sdp` - Answer SDP + +**Message format:** `answerOffer:{username}:{offerId}:{timestamp}` + +**Example:** +```json +{ + "method": "answerOffer", + "message": "answerOffer:bob:offer-hash:1733404800000", + "signature": "base64-signature", + "params": { + "serviceFqn": "chat:1.0.0@alice", + "offerId": "offer-hash", + "sdp": "v=0..." + } +} +``` + +**Response:** +```json +{ + "success": true, + "result": { + "success": true, + "offerId": "offer-hash" + } +} +``` + +### `getOfferAnswer` +Get answer for an offer (offerer polls this) + +**Parameters:** +- `serviceFqn` - Service FQN +- `offerId` - Offer ID + +**Message format:** `getOfferAnswer:{username}:{offerId}:{timestamp}` + +**Example:** +```json +{ + "method": "getOfferAnswer", + "message": "getOfferAnswer:alice:offer-hash:1733404800000", + "signature": "base64-signature", + "params": { + "serviceFqn": "chat:1.0.0@alice", + "offerId": "offer-hash" + } +} +``` + +**Response:** +```json +{ + "success": true, + "result": { + "sdp": "v=0...", + "offerId": "offer-hash", + "answererId": "bob", + "answeredAt": 1733404800000 + } +} +``` + +### `poll` +Combined polling for answers and ICE candidates + +**Parameters:** +- `since` - (optional) Timestamp to get only new data + +**Message format:** `poll:{username}:{timestamp}` + +**Example:** +```json +{ + "method": "poll", + "message": "poll:alice:1733404800000", + "signature": "base64-signature", + "params": { + "since": 1733404800000 + } +} +``` + +**Response:** +```json +{ + "success": true, + "result": { + "answers": [ + { + "offerId": "offer-hash", + "serviceId": "service-uuid", + "answererId": "bob", + "sdp": "v=0...", + "answeredAt": 1733404800000 + } + ], + "iceCandidates": { + "offer-hash": [ + { + "candidate": { "candidate": "...", "sdpMid": "0", "sdpMLineIndex": 0 }, + "role": "answerer", + "username": "bob", + "createdAt": 1733404800000 + } + ] + } + } +} +``` + +### `addIceCandidates` +Add ICE candidates to an offer + +**Parameters:** +- `serviceFqn` - Service FQN +- `offerId` - Offer ID +- `candidates` - Array of ICE candidates + +**Message format:** `addIceCandidates:{username}:{offerId}:{timestamp}` + +**Example:** +```json +{ + "method": "addIceCandidates", + "message": "addIceCandidates:alice:offer-hash:1733404800000", + "signature": "base64-signature", + "params": { + "serviceFqn": "chat:1.0.0@alice", + "offerId": "offer-hash", + "candidates": [ + { + "candidate": "candidate:...", + "sdpMid": "0", + "sdpMLineIndex": 0 } ] } } ``` -#### `POST /services/:fqn/offers/:offerId/ice-candidates` -Add ICE candidates to specific offer - -**Request:** +**Response:** ```json { - "username": "alice", - "candidates": [ - { - "candidate": "candidate:...", - "sdpMid": "0", - "sdpMLineIndex": 0 - } - ], - "signature": "base64-encoded-signature", - "message": "addIceCandidates:{username}:{offerId}:{timestamp}" + "success": true, + "result": { + "count": 1, + "offerId": "offer-hash" + } +} +``` + +### `getIceCandidates` +Get ICE candidates for an offer + +**Parameters:** +- `serviceFqn` - Service FQN +- `offerId` - Offer ID +- `since` - (optional) Timestamp to get only new candidates + +**Message format:** `getIceCandidates:{username}:{offerId}:{timestamp}` + +**Example:** +```json +{ + "method": "getIceCandidates", + "message": "getIceCandidates:alice:offer-hash:1733404800000", + "signature": "base64-signature", + "params": { + "serviceFqn": "chat:1.0.0@alice", + "offerId": "offer-hash", + "since": 1733404800000 + } } ``` **Response:** ```json { - "count": 1, - "offerId": "offer-hash" + "success": true, + "result": { + "candidates": [ + { + "candidate": { + "candidate": "candidate:...", + "sdpMid": "0", + "sdpMLineIndex": 0 + }, + "createdAt": 1733404800000 + } + ], + "offerId": "offer-hash" + } } ``` -#### `GET /services/:fqn/offers/:offerId/ice-candidates` -Get ICE candidates for specific offer - -**Query Parameters:** -- `username` - Your username -- `signature` - Base64-encoded Ed25519 signature -- `message` - Signed message (format: `getIceCandidates:{username}:{offerId}:{timestamp}`) -- `since` - Optional timestamp to get only new candidates - -**Response:** -```json -{ - "candidates": [ - { - "candidate": { - "candidate": "candidate:...", - "sdpMid": "0", - "sdpMLineIndex": 0 - }, - "createdAt": 1733404800000 - } - ], - "offerId": "offer-hash" -} -``` - -**Note:** Returns candidates from the opposite role (offerer gets answerer candidates and vice versa) - ## Configuration Environment variables: @@ -456,9 +583,9 @@ Environment variables: ### Ed25519 Signature Authentication All authenticated requests require: -- **username**: Your claimed username -- **signature**: Base64-encoded Ed25519 signature of the message - **message**: Signed message with format-specific structure +- **signature**: Base64-encoded Ed25519 signature of the message +- Username is extracted from the message ### Username Claiming - **Algorithm**: Ed25519 signatures @@ -474,7 +601,7 @@ All authenticated requests require: ### Service Publishing - **Ownership Verification**: Every publish requires username signature -- **Message Format**: `publish:{username}:{serviceFqn}:{timestamp}` +- **Message Format**: `publishService:{username}:{serviceFqn}:{timestamp}` - **Auto-Renewal**: Publishing a service extends username expiry ### ICE Candidate Filtering @@ -482,17 +609,15 @@ All authenticated requests require: - Offerers receive only answerer candidates - Answerers receive only offerer candidates -## Migration from v0.3.x +## Migration from v0.4.x See [MIGRATION.md](../MIGRATION.md) for detailed migration guide. **Key Changes:** -- Service FQN format changed from `service@version` to `service:version@username` -- Removed UUID privacy layer - direct FQN-based access -- Removed public/private service distinction -- Added service discovery (random and paginated) -- Unified polling endpoint (/poll replaces /offers/answered and /offers/poll) -- ICE candidate endpoints moved to offer-specific routes +- Moved from REST API to RPC interface with single `/rpc` endpoint +- All methods now use POST with JSON body +- Batch operations supported +- Authentication is per-method instead of per-endpoint middleware ## License diff --git a/src/app.ts b/src/app.ts index b833783..e1f6ad9 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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 = {}; - 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; diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts deleted file mode 100644 index 5d3f556..0000000 --- a/src/middleware/auth.ts +++ /dev/null @@ -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; -} diff --git a/src/rpc.ts b/src/rpc.ts new file mode 100644 index 0000000..ddc63c7 --- /dev/null +++ b/src/rpc.ts @@ -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; + +/** + * 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 = { + /** + * 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(); + 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 = {}; + + 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 { + 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; +}