mirror of
https://github.com/xtr-dev/rondevu-server.git
synced 2025-12-13 20:33:25 +00:00
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:
543
README.md
543
README.md
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
[](https://www.npmjs.com/package/@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:**
|
**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))
|
- [@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
|
## 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)
|
- **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 Publishing**: Service:version@username naming (e.g., `chat:1.0.0@alice`)
|
||||||
- **Service Discovery**: Random and paginated discovery for finding services without knowing usernames
|
- **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)
|
- **Semantic Versioning**: Compatible version matching (chat:1.0.0 matches any 1.x.x)
|
||||||
- **Signature-Based Authentication**: All authenticated requests use Ed25519 signatures
|
- **Signature-Based Authentication**: All authenticated requests use Ed25519 signatures
|
||||||
- **Complete WebRTC Signaling**: Offer/answer exchange and ICE candidate relay
|
- **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
|
- **Dual Storage**: SQLite (Node.js/Docker) and Cloudflare D1 (Workers) backends
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
@@ -58,67 +58,100 @@ docker build -t rondevu . && docker run -p 3000:3000 -e STORAGE_PATH=:memory: ro
|
|||||||
npx wrangler deploy
|
npx wrangler deploy
|
||||||
```
|
```
|
||||||
|
|
||||||
## API Endpoints
|
## RPC Interface
|
||||||
|
|
||||||
### Public Endpoints
|
All API calls are made to `POST /rpc` with JSON-RPC format.
|
||||||
|
|
||||||
#### `GET /`
|
### Request Format
|
||||||
Returns server version and info
|
|
||||||
|
|
||||||
**Response:**
|
**Single method call:**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"version": "0.4.0",
|
"method": "getUser",
|
||||||
"name": "Rondevu",
|
"message": "getUser:alice:1733404800000",
|
||||||
"description": "DNS-like WebRTC signaling with username claiming and service discovery"
|
"signature": "base64-encoded-signature",
|
||||||
|
"params": {
|
||||||
|
"username": "alice"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### `GET /health`
|
**Batch calls:**
|
||||||
Health check endpoint with version
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
```json
|
||||||
|
[
|
||||||
{
|
{
|
||||||
"status": "ok",
|
"method": "getUser",
|
||||||
"timestamp": 1733404800000,
|
"message": "getUser:alice:1733404800000",
|
||||||
"version": "0.4.0"
|
"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"
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
### User Management
|
### Response Format
|
||||||
|
|
||||||
#### `GET /users/:username`
|
**Single response:**
|
||||||
Check username availability and claim status
|
|
||||||
|
|
||||||
**Response (Available):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"username": "alice",
|
|
||||||
"available": true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response (Claimed):**
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
"success": true,
|
||||||
|
"result": {
|
||||||
"username": "alice",
|
"username": "alice",
|
||||||
"available": false,
|
"available": false,
|
||||||
"claimedAt": 1733404800000,
|
"claimedAt": 1733404800000,
|
||||||
"expiresAt": 1765027200000,
|
"expiresAt": 1765027200000,
|
||||||
"publicKey": "base64-encoded-ed25519-public-key"
|
"publicKey": "base64-encoded-public-key"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### `POST /users/:username`
|
**Batch responses:**
|
||||||
Claim a username with cryptographic proof
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"result": { "username": "alice", "available": false }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"result": { "success": true, "username": "bob" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
**Request:**
|
**Error response:**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"publicKey": "base64-encoded-ed25519-public-key",
|
"success": false,
|
||||||
"signature": "base64-encoded-signature",
|
"error": "Username already claimed by different public key"
|
||||||
"message": "claim:alice:1733404800000"
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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" }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -126,40 +159,125 @@ Claim a username with cryptographic proof
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"username": "alice"
|
"result": {
|
||||||
|
"username": "alice",
|
||||||
|
"available": false,
|
||||||
|
"claimedAt": 1733404800000,
|
||||||
|
"expiresAt": 1765027200000,
|
||||||
|
"publicKey": "base64-encoded-public-key"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Validation:**
|
### `claimUsername`
|
||||||
- Username format: `^[a-z0-9][a-z0-9-]*[a-z0-9]$` (3-32 characters)
|
Claim a username with cryptographic proof
|
||||||
- Signature must be valid Ed25519 signature
|
|
||||||
- Timestamp must be within 5 minutes (replay protection)
|
|
||||||
- Expires after 365 days, auto-renewed on use
|
|
||||||
|
|
||||||
### Service Management
|
**Parameters:**
|
||||||
|
- `username` - Username to claim
|
||||||
|
- `publicKey` - Base64-encoded Ed25519 public key
|
||||||
|
|
||||||
#### `POST /services`
|
**Message format:** `claim:{username}:{timestamp}`
|
||||||
Publish a service with offers (requires username and signature)
|
|
||||||
|
|
||||||
**Request:**
|
**Example:**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
"method": "claimUsername",
|
||||||
|
"message": "claim:alice:1733404800000",
|
||||||
|
"signature": "base64-signature",
|
||||||
|
"params": {
|
||||||
"username": "alice",
|
"username": "alice",
|
||||||
"serviceFqn": "chat:1.0.0@alice",
|
"publicKey": "base64-encoded-public-key"
|
||||||
"offers": [
|
}
|
||||||
{ "sdp": "v=0..." },
|
|
||||||
{ "sdp": "v=0..." }
|
|
||||||
],
|
|
||||||
"ttl": 300000,
|
|
||||||
"signature": "base64-encoded-signature",
|
|
||||||
"message": "publish:alice:chat:1.0.0@alice:1733404800000"
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Response:**
|
**Response:**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"serviceId": "uuid-v4",
|
"success": true,
|
||||||
|
"result": {
|
||||||
|
"success": true,
|
||||||
|
"username": "alice"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `getService`
|
||||||
|
Get service by FQN (direct lookup, random discovery, or paginated)
|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
|
**Message format:** `getService:{username}:{serviceFqn}:{timestamp}`
|
||||||
|
|
||||||
|
**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
|
||||||
|
{
|
||||||
|
"method": "getService",
|
||||||
|
"message": "getService:bob:chat:1.0.0:1733404800000",
|
||||||
|
"signature": "base64-signature",
|
||||||
|
"params": {
|
||||||
|
"serviceFqn": "chat:1.0.0@alice"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"result": {
|
||||||
|
"serviceId": "uuid",
|
||||||
|
"username": "alice",
|
||||||
|
"serviceFqn": "chat:1.0.0@alice",
|
||||||
|
"offerId": "offer-hash",
|
||||||
|
"sdp": "v=0...",
|
||||||
|
"createdAt": 1733404800000,
|
||||||
|
"expiresAt": 1733405100000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `publishService`
|
||||||
|
Publish a service with offers
|
||||||
|
|
||||||
|
**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
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"result": {
|
||||||
|
"serviceId": "uuid",
|
||||||
"username": "alice",
|
"username": "alice",
|
||||||
"serviceFqn": "chat:1.0.0@alice",
|
"serviceFqn": "chat:1.0.0@alice",
|
||||||
"offers": [
|
"offers": [
|
||||||
@@ -173,159 +291,137 @@ Publish a service with offers (requires username and signature)
|
|||||||
"createdAt": 1733404800000,
|
"createdAt": 1733404800000,
|
||||||
"expiresAt": 1733405100000
|
"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):**
|
### `deleteService`
|
||||||
|
Delete a service
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `serviceFqn` - Service FQN with username
|
||||||
|
|
||||||
|
**Message format:** `deleteService:{username}:{serviceFqn}:{timestamp}`
|
||||||
|
|
||||||
|
**Example:**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"services": [
|
"method": "deleteService",
|
||||||
{
|
"message": "deleteService:alice:chat:1.0.0@alice:1733404800000",
|
||||||
"serviceId": "uuid",
|
"signature": "base64-signature",
|
||||||
"username": "alice",
|
"params": {
|
||||||
"serviceFqn": "chat:1.0.0@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}"
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Response:**
|
**Response:**
|
||||||
```json
|
```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,
|
"success": true,
|
||||||
"offerId": "offer-hash"
|
"offerId": "offer-hash"
|
||||||
}
|
}
|
||||||
```
|
|
||||||
|
|
||||||
#### `GET /services/:fqn/offers/:offerId/answer`
|
|
||||||
Get answer SDP (offerer polls this)
|
|
||||||
|
|
||||||
**Query Parameters:**
|
|
||||||
- `username` - Your username
|
|
||||||
- `signature` - Base64-encoded Ed25519 signature
|
|
||||||
- `message` - Signed message (format: `getAnswer:{username}:{offerId}:{timestamp}`)
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"sdp": "v=0...",
|
|
||||||
"offerId": "offer-hash",
|
|
||||||
"answererUsername": "bob",
|
|
||||||
"answeredAt": 1733404800000
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Returns 404 if not yet answered.
|
### `getOfferAnswer`
|
||||||
|
Get answer for an offer (offerer polls this)
|
||||||
|
|
||||||
#### `GET /poll`
|
**Parameters:**
|
||||||
Combined polling endpoint for answers and ICE candidates
|
- `serviceFqn` - Service FQN
|
||||||
|
- `offerId` - Offer ID
|
||||||
|
|
||||||
**Query Parameters:**
|
**Message format:** `getOfferAnswer:{username}:{offerId}:{timestamp}`
|
||||||
- `username` - Your username
|
|
||||||
- `signature` - Base64-encoded Ed25519 signature
|
**Example:**
|
||||||
- `message` - Signed message (format: `poll:{username}:{timestamp}`)
|
```json
|
||||||
- `since` - Optional timestamp to get only new data
|
{
|
||||||
|
"method": "getOfferAnswer",
|
||||||
|
"message": "getOfferAnswer:alice:offer-hash:1733404800000",
|
||||||
|
"signature": "base64-signature",
|
||||||
|
"params": {
|
||||||
|
"serviceFqn": "chat:1.0.0@alice",
|
||||||
|
"offerId": "offer-hash"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
**Response:**
|
**Response:**
|
||||||
```json
|
```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": [
|
"answers": [
|
||||||
{
|
{
|
||||||
"offerId": "offer-hash",
|
"offerId": "offer-hash",
|
||||||
"serviceId": "service-uuid",
|
"serviceId": "service-uuid",
|
||||||
"answererUsername": "bob",
|
"answererId": "bob",
|
||||||
"sdp": "v=0...",
|
"sdp": "v=0...",
|
||||||
"answeredAt": 1733404800000
|
"answeredAt": 1733404800000
|
||||||
}
|
}
|
||||||
@@ -341,47 +437,79 @@ Combined polling endpoint for answers and ICE candidates
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### `POST /services/:fqn/offers/:offerId/ice-candidates`
|
### `addIceCandidates`
|
||||||
Add ICE candidates to specific offer
|
Add ICE candidates to an offer
|
||||||
|
|
||||||
**Request:**
|
**Parameters:**
|
||||||
|
- `serviceFqn` - Service FQN
|
||||||
|
- `offerId` - Offer ID
|
||||||
|
- `candidates` - Array of ICE candidates
|
||||||
|
|
||||||
|
**Message format:** `addIceCandidates:{username}:{offerId}:{timestamp}`
|
||||||
|
|
||||||
|
**Example:**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"username": "alice",
|
"method": "addIceCandidates",
|
||||||
|
"message": "addIceCandidates:alice:offer-hash:1733404800000",
|
||||||
|
"signature": "base64-signature",
|
||||||
|
"params": {
|
||||||
|
"serviceFqn": "chat:1.0.0@alice",
|
||||||
|
"offerId": "offer-hash",
|
||||||
"candidates": [
|
"candidates": [
|
||||||
{
|
{
|
||||||
"candidate": "candidate:...",
|
"candidate": "candidate:...",
|
||||||
"sdpMid": "0",
|
"sdpMid": "0",
|
||||||
"sdpMLineIndex": 0
|
"sdpMLineIndex": 0
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"signature": "base64-encoded-signature",
|
}
|
||||||
"message": "addIceCandidates:{username}:{offerId}:{timestamp}"
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Response:**
|
**Response:**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
"success": true,
|
||||||
|
"result": {
|
||||||
"count": 1,
|
"count": 1,
|
||||||
"offerId": "offer-hash"
|
"offerId": "offer-hash"
|
||||||
}
|
}
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### `GET /services/:fqn/offers/:offerId/ice-candidates`
|
### `getIceCandidates`
|
||||||
Get ICE candidates for specific offer
|
Get ICE candidates for an offer
|
||||||
|
|
||||||
**Query Parameters:**
|
**Parameters:**
|
||||||
- `username` - Your username
|
- `serviceFqn` - Service FQN
|
||||||
- `signature` - Base64-encoded Ed25519 signature
|
- `offerId` - Offer ID
|
||||||
- `message` - Signed message (format: `getIceCandidates:{username}:{offerId}:{timestamp}`)
|
- `since` - (optional) Timestamp to get only new candidates
|
||||||
- `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:**
|
**Response:**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
"success": true,
|
||||||
|
"result": {
|
||||||
"candidates": [
|
"candidates": [
|
||||||
{
|
{
|
||||||
"candidate": {
|
"candidate": {
|
||||||
@@ -394,10 +522,9 @@ Get ICE candidates for specific offer
|
|||||||
],
|
],
|
||||||
"offerId": "offer-hash"
|
"offerId": "offer-hash"
|
||||||
}
|
}
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Note:** Returns candidates from the opposite role (offerer gets answerer candidates and vice versa)
|
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Environment variables:
|
Environment variables:
|
||||||
@@ -456,9 +583,9 @@ Environment variables:
|
|||||||
|
|
||||||
### Ed25519 Signature Authentication
|
### Ed25519 Signature Authentication
|
||||||
All authenticated requests require:
|
All authenticated requests require:
|
||||||
- **username**: Your claimed username
|
|
||||||
- **signature**: Base64-encoded Ed25519 signature of the message
|
|
||||||
- **message**: Signed message with format-specific structure
|
- **message**: Signed message with format-specific structure
|
||||||
|
- **signature**: Base64-encoded Ed25519 signature of the message
|
||||||
|
- Username is extracted from the message
|
||||||
|
|
||||||
### Username Claiming
|
### Username Claiming
|
||||||
- **Algorithm**: Ed25519 signatures
|
- **Algorithm**: Ed25519 signatures
|
||||||
@@ -474,7 +601,7 @@ All authenticated requests require:
|
|||||||
|
|
||||||
### Service Publishing
|
### Service Publishing
|
||||||
- **Ownership Verification**: Every publish requires username signature
|
- **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
|
- **Auto-Renewal**: Publishing a service extends username expiry
|
||||||
|
|
||||||
### ICE Candidate Filtering
|
### ICE Candidate Filtering
|
||||||
@@ -482,17 +609,15 @@ All authenticated requests require:
|
|||||||
- Offerers receive only answerer candidates
|
- Offerers receive only answerer candidates
|
||||||
- Answerers receive only offerer 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.
|
See [MIGRATION.md](../MIGRATION.md) for detailed migration guide.
|
||||||
|
|
||||||
**Key Changes:**
|
**Key Changes:**
|
||||||
- Service FQN format changed from `service@version` to `service:version@username`
|
- Moved from REST API to RPC interface with single `/rpc` endpoint
|
||||||
- Removed UUID privacy layer - direct FQN-based access
|
- All methods now use POST with JSON body
|
||||||
- Removed public/private service distinction
|
- Batch operations supported
|
||||||
- Added service discovery (random and paginated)
|
- Authentication is per-method instead of per-endpoint middleware
|
||||||
- Unified polling endpoint (/poll replaces /offers/answered and /offers/poll)
|
|
||||||
- ICE candidate endpoints moved to offer-specific routes
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
626
src/app.ts
626
src/app.ts
@@ -2,20 +2,14 @@ import { Hono } from 'hono';
|
|||||||
import { cors } from 'hono/cors';
|
import { cors } from 'hono/cors';
|
||||||
import { Storage } from './storage/types.ts';
|
import { Storage } from './storage/types.ts';
|
||||||
import { Config } from './config.ts';
|
import { Config } from './config.ts';
|
||||||
import { createAuthMiddleware, getAuthenticatedUsername } from './middleware/auth.ts';
|
import { handleRpc, RpcRequest } from './rpc.ts';
|
||||||
import { validateUsernameClaim, validateServicePublish, validateServiceFqn, parseServiceFqn, isVersionCompatible } from './crypto.ts';
|
|
||||||
import type { Context } from 'hono';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates the Hono application with username and service-based WebRTC signaling
|
* Creates the Hono application with RPC interface
|
||||||
* RESTful API design - v0.11.0
|
|
||||||
*/
|
*/
|
||||||
export function createApp(storage: Storage, config: Config) {
|
export function createApp(storage: Storage, config: Config) {
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
// Create auth middleware
|
|
||||||
const authMiddleware = createAuthMiddleware(storage);
|
|
||||||
|
|
||||||
// Enable CORS
|
// Enable CORS
|
||||||
app.use('/*', cors({
|
app.use('/*', cors({
|
||||||
origin: (origin) => {
|
origin: (origin) => {
|
||||||
@@ -27,622 +21,70 @@ export function createApp(storage: Storage, config: Config) {
|
|||||||
}
|
}
|
||||||
return config.corsOrigins[0];
|
return config.corsOrigins[0];
|
||||||
},
|
},
|
||||||
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
allowMethods: ['GET', 'POST', 'OPTIONS'],
|
||||||
allowHeaders: ['Content-Type', 'Origin', 'Authorization'],
|
allowHeaders: ['Content-Type', 'Origin'],
|
||||||
exposeHeaders: ['Content-Type'],
|
exposeHeaders: ['Content-Type'],
|
||||||
maxAge: 600,
|
credentials: false,
|
||||||
credentials: true,
|
maxAge: 86400,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// ===== General Endpoints =====
|
// Root endpoint - server info
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /
|
|
||||||
* Returns server information
|
|
||||||
*/
|
|
||||||
app.get('/', (c) => {
|
app.get('/', (c) => {
|
||||||
return c.json({
|
return c.json({
|
||||||
version: config.version,
|
version: config.version,
|
||||||
name: 'Rondevu',
|
name: 'Rondevu',
|
||||||
description: 'DNS-like WebRTC signaling with username claiming and service discovery'
|
description: 'WebRTC signaling with RPC interface and Ed25519 authentication',
|
||||||
});
|
}, 200);
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
// Health check
|
||||||
* GET /health
|
|
||||||
* Health check endpoint
|
|
||||||
*/
|
|
||||||
app.get('/health', (c) => {
|
app.get('/health', (c) => {
|
||||||
return c.json({
|
return c.json({
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
version: config.version
|
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);
|
}, 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);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /users/:username
|
* POST /rpc
|
||||||
* Claim a username with cryptographic proof
|
* RPC endpoint - accepts single or batch method calls
|
||||||
*/
|
*/
|
||||||
app.post('/users/:username', async (c) => {
|
app.post('/rpc', async (c) => {
|
||||||
try {
|
try {
|
||||||
const username = c.req.param('username');
|
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
const { publicKey, signature, message } = body;
|
|
||||||
|
|
||||||
if (!publicKey || !signature || !message) {
|
// Support both single request and batch array
|
||||||
return c.json({ error: 'Missing required parameters: publicKey, signature, message' }, 400);
|
const requests: RpcRequest[] = Array.isArray(body) ? body : [body];
|
||||||
|
|
||||||
|
// Validate requests
|
||||||
|
if (requests.length === 0) {
|
||||||
|
return c.json({ error: 'Empty request array' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate claim
|
if (requests.length > 100) {
|
||||||
const validation = await validateUsernameClaim(username, publicKey, signature, message);
|
return c.json({ error: 'Too many requests in batch (max 100)' }, 400);
|
||||||
if (!validation.valid) {
|
|
||||||
return c.json({ error: validation.error }, 400);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attempt to claim username
|
// Handle RPC
|
||||||
try {
|
const responses = await handleRpc(requests, storage, config);
|
||||||
const claimed = await storage.claimUsername({
|
|
||||||
username,
|
|
||||||
publicKey,
|
|
||||||
signature,
|
|
||||||
message
|
|
||||||
});
|
|
||||||
|
|
||||||
return c.json({
|
// Return single response or array based on input
|
||||||
username: claimed.username,
|
return c.json(Array.isArray(body) ? responses : responses[0], 200);
|
||||||
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) {
|
} catch (err) {
|
||||||
console.error('Error claiming username:', err);
|
console.error('RPC error:', err);
|
||||||
return c.json({ error: 'Internal server error' }, 500);
|
return c.json({
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid request format',
|
||||||
|
}, 400);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ===== Service Discovery and Management =====
|
// 404 for all other routes
|
||||||
|
app.all('*', (c) => {
|
||||||
/**
|
|
||||||
* 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({
|
return c.json({
|
||||||
error: 'No available offers',
|
error: 'Not found. Use POST /rpc for all API calls.',
|
||||||
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);
|
}, 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[] = [];
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate and parse service FQN
|
|
||||||
const fqnValidation = validateServiceFqn(serviceFqn);
|
|
||||||
if (!fqnValidation.valid) {
|
|
||||||
return c.json({ error: fqnValidation.error }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
} 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)
|
|
||||||
});
|
|
||||||
return c.json({
|
|
||||||
error: 'Internal server error',
|
|
||||||
details: (err as Error).message
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
|
|||||||
@@ -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
721
src/rpc.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user