BREAKING CHANGE: Replace offer-based endpoints with service-based signaling - Add POST /services/:uuid/answer - Add GET /services/:uuid/answer - Add POST /services/:uuid/ice-candidates - Add GET /services/:uuid/ice-candidates - Remove all /offers/* endpoints (POST /offers, GET /offers/mine, etc.) - Server auto-detects peer's offer when offerId is omitted - Update README with new service-based API documentation - Bump version to 0.4.0 This change simplifies the API by focusing on services rather than individual offers. WebRTC signaling (answer/ICE) now operates at the service level, with automatic offer detection when needed.
10 KiB
Rondevu Server
🌐 DNS-like WebRTC signaling with username claiming and service discovery
Scalable WebRTC signaling server with cryptographic username claiming, service publishing, and privacy-preserving discovery.
Related repositories:
- @xtr-dev/rondevu-client - TypeScript client library (npm)
- @xtr-dev/rondevu-server - HTTP signaling server (npm, live)
- @xtr-dev/rondevu-demo - Interactive demo (live)
Features
- Username Claiming: Cryptographic username ownership with Ed25519 signatures (365-day validity, auto-renewed on use)
- Service Publishing: Package-style naming with semantic versioning (com.example.chat@1.0.0)
- Privacy-Preserving Discovery: UUID-based service index prevents enumeration
- Public/Private Services: Control service visibility
- Stateless Authentication: AES-256-GCM encrypted credentials, no server-side sessions
- Complete WebRTC Signaling: Offer/answer exchange and ICE candidate relay
- Dual Storage: SQLite (Node.js/Docker) and Cloudflare D1 (Workers) backends
Architecture
Username Claiming → Service Publishing → Service Discovery → WebRTC Connection
alice claims "alice" with Ed25519 signature
↓
alice publishes com.example.chat@1.0.0 with multiple offers → receives UUID abc123
↓
bob requests alice/com.example.chat@1.0.0 → gets compatible service with available offer
↓
WebRTC connection established via offer/answer exchange
Quick Start
Node.js:
npm install && npm start
Docker:
docker build -t rondevu . && docker run -p 3000:3000 -e STORAGE_PATH=:memory: -e AUTH_SECRET=$(openssl rand -hex 32) rondevu
Cloudflare Workers:
npx wrangler deploy
API Endpoints
Public Endpoints
GET /
Returns server version and info
GET /health
Health check endpoint with version
POST /register
Register a new peer and receive credentials (peerId + secret)
Generates a cryptographically random 128-bit peer ID.
Response:
{
"peerId": "f17c195f067255e357232e34cf0735d9",
"secret": "DdorTR8QgSn9yngn+4qqR8cs1aMijvX..."
}
User Management (RESTful)
GET /users/:username
Check username availability and claim status
Response:
{
"username": "alice",
"available": false,
"claimedAt": 1733404800000,
"expiresAt": 1765027200000,
"publicKey": "..."
}
POST /users/:username
Claim a username with cryptographic proof
Request:
{
"publicKey": "base64-encoded-ed25519-public-key",
"signature": "base64-encoded-signature",
"message": "claim:alice:1733404800000"
}
Response:
{
"username": "alice",
"claimedAt": 1733404800000,
"expiresAt": 1765027200000
}
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
GET /users/:username/services/:fqn
Get service by username and FQN with semver-compatible matching
Semver Matching:
- Requesting
chat@1.0.0matches any1.x.xversion - Major version must match exactly (
chat@1.0.0will NOT matchchat@2.0.0) - For major version 0, minor must also match (
0.1.0will NOT match0.2.0) - Returns the most recently published compatible version
Response:
{
"uuid": "abc123",
"serviceId": "service-id",
"username": "alice",
"serviceFqn": "chat.app@1.0.0",
"offerId": "offer-hash",
"sdp": "v=0...",
"isPublic": true,
"metadata": {},
"createdAt": 1733404800000,
"expiresAt": 1733405100000
}
Note: Returns a single available offer from the service. If all offers are in use, returns 503.
Service Management (RESTful)
POST /users/:username/services
Publish a service with multiple offers (requires authentication and username signature)
Headers:
Authorization: Bearer {peerId}:{secret}
Request:
{
"serviceFqn": "com.example.chat@1.0.0",
"offers": [
{ "sdp": "v=0..." },
{ "sdp": "v=0..." }
],
"ttl": 300000,
"isPublic": false,
"metadata": { "description": "Chat service" },
"signature": "base64-encoded-signature",
"message": "publish:alice:com.example.chat@1.0.0:1733404800000"
}
Response (Full service details):
{
"uuid": "uuid-v4-for-index",
"serviceId": "uuid-v4",
"username": "alice",
"serviceFqn": "com.example.chat@1.0.0",
"offers": [
{
"offerId": "offer-hash-1",
"sdp": "v=0...",
"createdAt": 1733404800000,
"expiresAt": 1733405100000
},
{
"offerId": "offer-hash-2",
"sdp": "v=0...",
"createdAt": 1733404800000,
"expiresAt": 1733405100000
}
],
"isPublic": false,
"metadata": { "description": "Chat service" },
"createdAt": 1733404800000,
"expiresAt": 1733405100000
}
Service FQN Format:
- Service name: Reverse domain notation (e.g.,
com.example.chat) - Version: Semantic versioning (e.g.,
1.0.0,2.1.3-beta) - Complete FQN:
service-name@version(e.g.,com.example.chat@1.0.0)
Validation:
- Service name pattern:
^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$ - Length: 3-128 characters
- Version pattern:
^[0-9]+\.[0-9]+\.[0-9]+(-[a-z0-9.-]+)?$
GET /services/:uuid
Get service details by UUID
Response:
{
"serviceId": "...",
"username": "alice",
"serviceFqn": "com.example.chat@1.0.0",
"offerId": "...",
"sdp": "v=0...",
"isPublic": false,
"metadata": { ... },
"createdAt": 1733404800000,
"expiresAt": 1733405100000
}
DELETE /users/:username/services/:fqn
Unpublish a service (requires authentication and ownership)
Headers:
Authorization: Bearer {peerId}:{secret}
Request:
{
"username": "alice"
}
WebRTC Signaling (Service-Based)
POST /services/:uuid/answer
Answer a service offer (requires authentication)
Headers:
Authorization: Bearer {peerId}:{secret}
Request:
{
"sdp": "v=0..."
}
Response:
{
"success": true,
"offerId": "offer-hash"
}
GET /services/:uuid/answer
Get answer for a service (offerer polls this)
Headers:
Authorization: Bearer {peerId}:{secret}
Response:
{
"offerId": "offer-hash",
"answererId": "answerer-peer-id",
"sdp": "v=0...",
"answeredAt": 1733404800000
}
Note: Returns 404 if not yet answered
POST /services/:uuid/ice-candidates
Post ICE candidates for a service (requires authentication)
Headers:
Authorization: Bearer {peerId}:{secret}
Request:
{
"candidates": ["candidate:1 1 UDP..."],
"offerId": "optional-offer-id"
}
Response:
{
"count": 1,
"offerId": "offer-hash"
}
Note: If offerId is omitted, the server will auto-detect the peer's offer
GET /services/:uuid/ice-candidates?since=1234567890&offerId=optional-offer-id
Get ICE candidates from the other peer (requires authentication)
Headers:
Authorization: Bearer {peerId}:{secret}
Response:
{
"candidates": [
{
"candidate": "candidate:1 1 UDP...",
"createdAt": 1733404800000
}
],
"offerId": "offer-hash"
}
Note: Returns candidates from the opposite role (offerer gets answerer candidates and vice versa)
Configuration
Environment variables:
| Variable | Default | Description |
|---|---|---|
PORT |
3000 |
Server port (Node.js/Docker) |
CORS_ORIGINS |
* |
Comma-separated allowed origins |
STORAGE_PATH |
./rondevu.db |
SQLite database path (use :memory: for in-memory) |
VERSION |
2.0.0 |
Server version (semver) |
AUTH_SECRET |
Random 32-byte hex | Secret key for credential encryption (required for production) |
OFFER_DEFAULT_TTL |
300000 |
Default offer TTL in ms (5 minutes) |
OFFER_MIN_TTL |
60000 |
Minimum offer TTL in ms (1 minute) |
OFFER_MAX_TTL |
3600000 |
Maximum offer TTL in ms (1 hour) |
MAX_OFFERS_PER_REQUEST |
10 |
Maximum offers per create request |
Database Schema
usernames
username(PK): Claimed usernamepublic_key: Ed25519 public key (base64)claimed_at: Claim timestampexpires_at: Expiry timestamp (365 days)last_used: Last activity timestampmetadata: Optional JSON metadata
services
id(PK): Service ID (UUID)username(FK): Owner usernameservice_fqn: Fully qualified name (com.example.chat@1.0.0)is_public: Public/private flagmetadata: JSON metadatacreated_at,expires_at: Timestamps
offers
id(PK): Offer ID (hash of SDP)peer_id(FK): Owner peer IDservice_id(FK): Optional link to service (null for standalone offers)sdp: WebRTC offer SDPanswerer_peer_id: Peer ID of answerer (null until answered)answer_sdp: WebRTC answer SDP (null until answered)created_at,expires_at,last_seen: Timestamps
service_index (privacy layer)
uuid(PK): Random UUID for discoveryservice_id(FK): Links to serviceusername,service_fqn: Denormalized for performance
Security
Username Claiming
- Algorithm: Ed25519 signatures
- Message Format:
claim:{username}:{timestamp} - Replay Protection: Timestamp must be within 5 minutes
- Key Management: Private keys never leave the client
Service Publishing
- Ownership Verification: Every publish requires username signature
- Message Format:
publish:{username}:{serviceFqn}:{timestamp} - Auto-Renewal: Publishing a service extends username expiry
Privacy
- Private Services: Only UUID exposed, FQN hidden
- Public Services: FQN and metadata visible
- No Enumeration: Cannot list all services without knowing FQN
Migration from V1
V2 is a breaking change that removes topic-based discovery. See MIGRATION.md for detailed migration guide.
Key Changes:
- ❌ Removed: Topic-based discovery, bloom filters, public peer listings
- ✅ Added: Username claiming, service publishing, UUID-based privacy
License
MIT