21 Commits

Author SHA1 Message Date
3efed6e9d2 Fix service reconnection: return available offer from pool
Modified /services/:uuid endpoint to return an available (unanswered)
offer from the service's offer pool instead of always returning the
initial offer. This fixes reconnection failures where clients would
try to answer already-consumed offers.

Changes:
- Query all offers from the service's peer ID
- Return first unanswered offer
- Return 503 if no offers available

Fixes: "Offer already answered" errors on reconnection attempts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-06 13:47:00 +01:00
1257867dff fix: implement upsert behavior for service creation
When a service is republished (e.g., for TTL refresh), the old service
is now deleted before creating a new one, preventing UNIQUE constraint
errors on (username, service_fqn).

Changes:
- Query for existing service before creation
- Delete existing service if found
- Create new service with same username/serviceFqn

This enables the client's TTL auto-refresh feature to work correctly.
2025-12-06 13:04:45 +01:00
52cf734858 Remove legacy V1 code and clean up unused remnants
- Delete unused bloom.ts module (leftover from topic-based discovery)
- Remove maxTopicsPerOffer configuration (no longer used)
- Remove unused info field from Offer types
- Simplify generateOfferHash() to only hash SDP (remove topics param)
- Update outdated comments referencing deprecated features
- Remove backward compatibility topics field from answer responses

This completes the migration to V2 service-based architecture by
removing all remnants of the V1 topic-based system.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-06 12:06:02 +01:00
5622867411 Add upsert behavior to service creation
- Delete existing service before creating new one
- Prevents UNIQUE constraint error on (username, service_fqn)
- Enables seamless service republishing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-06 11:46:21 +01:00
ac0e064e34 Fix answer response field names for V2 API compatibility
- Change 'answererPeerId' to 'answererId'
- Change 'answerSdp' to 'sdp'
- Add 'topics' field (empty array) for client compatibility

This ensures the server response matches the expected format
in the client's AnsweredOffer interface.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-06 11:37:31 +01:00
e7cd90b905 Fix error handling scope issue in service creation
The error handler was referencing variables (username, serviceFqn, offers)
that were declared inside the try block. If an error occurred before these
were defined, the error handler itself would fail, resulting in non-JSON
responses that caused "JSON.parse: unexpected character" errors on the client.

Fixed by:
- Declaring variables at function scope
- Initializing offers as empty array
- Using destructuring assignment for username/serviceFqn

This ensures the error handler can always access these variables safely,
even if an early error occurs, and will always return proper JSON responses.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 19:56:06 +01:00
67b1decbad debug: add detailed error logging to service creation endpoint
Return error details in response to help debug internal server errors

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 19:37:57 +01:00
e9d0f26726 fix: add validateServicePublish for correct signature verification
The service publishing endpoint was using validateUsernameClaim which
expects the message format "claim:{username}:{timestamp}", but clients
send "publish:{username}:{serviceFqn}:{timestamp}".

Added validateServicePublish function to properly validate service
publishing signatures with the correct message format.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 19:31:42 +01:00
595eac8692 feat: add V2 database migration for D1
Add migration to create V2 tables:
- offers (with ICE candidates)
- usernames (with Ed25519 public keys)
- services (with service discovery)
- service_index (privacy layer)

Applied to production D1 database: rondevu-offers

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 19:24:29 +01:00
65a13fefa4 fix: use async ed25519.verifyAsync function
Switch from sync verify() to async verifyAsync() to work with
hashes.sha512Async which uses WebCrypto API.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 19:19:47 +01:00
1dadf5461e fix: use Web Crypto API for Cloudflare Workers compatibility
- d1.ts: Use global crypto.randomUUID() instead of importing from 'crypto'
- sqlite.ts: Use 'node:crypto' import for Node.js compatibility

This fixes the Cloudflare Workers deployment error:
"The package 'crypto' wasn't found on the file system but is built into node"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 19:05:23 +01:00
bd35f7919c chore: bump version to 0.2.1
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 18:46:00 +01:00
683bc42bf0 fix: initialize SHA-512 hash function for @noble/ed25519 v3
@noble/ed25519 v3.0.0 requires explicit SHA-512 hash function setup
before using any cryptographic operations.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 18:45:04 +01:00
c3fc498c81 fix: correct server version to 0.2.0 (minor bump from 0.1.4)
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 18:30:29 +01:00
4f772c50c9 feat: add V2 service publishing and username claiming APIs
- Add POST /services endpoint for publishing services with username verification
- Add DELETE /services/:serviceId endpoint for unpublishing services
- Add GET /services/:serviceFqn endpoint for service discovery
- Add POST /usernames/claim endpoint with Ed25519 signature verification
- Add POST /usernames/renew endpoint for extending username TTL
- Add GET /usernames/:username endpoint for checking username availability
- Add username expiry tracking and cleanup (365-day default TTL)
- Add service-to-offer relationship tracking
- Add signature verification for username operations
- Update storage schema for usernames and services tables
- Add comprehensive README documentation for V2 APIs
- Update version to 0.8.0

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 18:27:12 +01:00
08e1433088 Update README: Remove custom peer ID documentation
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-22 23:23:09 +01:00
70d018c666 Remove custom peer ID feature for security
Always generate cryptographically random 128-bit peer IDs to prevent peer ID hijacking vulnerability. This ensures peer IDs are secure through collision resistance rather than relying on expiration-based protection.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-22 23:19:16 +01:00
2cff4c8544 0.1.4 2025-11-22 17:32:56 +01:00
00499732c4 Add optional info field to offers
- Add info field to Offer and CreateOfferRequest types
- Validate info field: optional, max 128 characters
- Include info field in all public API responses
- Update README with info field documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-22 17:32:56 +01:00
341d043358 0.1.3 2025-11-22 16:05:36 +01:00
23c27d4509 Add custom peer ID support to register endpoint
- Update /register endpoint to accept optional custom peer ID
- Add validation: 1-128 chars, non-empty, must be unique
- Return 409 Conflict if peer ID already in use
- Remove outdated API.md documentation
- Update README.md with new register endpoint format

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-22 16:05:25 +01:00
15 changed files with 1713 additions and 1148 deletions

458
API.md
View File

@@ -1,458 +0,0 @@
# HTTP API
This API provides peer signaling and tracking endpoints for distributed peer-to-peer applications. Uses JSON request/response bodies with Origin-based session isolation.
All endpoints require an `Origin` header and accept `application/json` content type.
---
## Overview
Sessions are organized by:
- **Origin**: The HTTP Origin header (e.g., `https://example.com`) - isolates sessions by application
- **Topic**: A string identifier for grouping related peers (max 256 chars)
- **Info**: User-provided metadata (max 1024 chars) to uniquely identify each peer
This allows multiple peers from the same application (origin) to discover each other through topics while preventing duplicate connections by comparing the info field.
---
## GET `/`
Returns server version information including the git commit hash used to build the server.
### Response
**Content-Type:** `application/json`
**Success (200 OK):**
```json
{
"version": "a1b2c3d"
}
```
**Notes:**
- Returns the git commit hash from build time
- Returns "unknown" if git information is not available
### Example
```bash
curl -X GET http://localhost:3000/
```
---
## GET `/topics`
Lists all topics with the count of available peers for each (paginated). Returns only topics that have unanswered sessions.
### Request
**Headers:**
- `Origin: https://example.com` (required)
**Query Parameters:**
| Parameter | Type | Required | Default | Description |
|-----------|--------|----------|---------|---------------------------------|
| `page` | number | No | `1` | Page number (starting from 1) |
| `limit` | number | No | `100` | Results per page (max 1000) |
### Response
**Content-Type:** `application/json`
**Success (200 OK):**
```json
{
"topics": [
{
"topic": "my-room",
"count": 3
},
{
"topic": "another-room",
"count": 1
}
],
"pagination": {
"page": 1,
"limit": 100,
"total": 2,
"hasMore": false
}
}
```
**Notes:**
- Only returns topics from the same origin as the request
- Only includes topics with at least one unanswered session
- Topics are sorted alphabetically
- Counts only include unexpired sessions
- Maximum 1000 results per page
### Examples
**Default pagination (page 1, limit 100):**
```bash
curl -X GET http://localhost:3000/topics \
-H "Origin: https://example.com"
```
**Custom pagination:**
```bash
curl -X GET "http://localhost:3000/topics?page=2&limit=50" \
-H "Origin: https://example.com"
```
---
## GET `/:topic/sessions`
Discovers available peers for a given topic. Returns all unanswered sessions from the requesting origin.
### Request
**Headers:**
- `Origin: https://example.com` (required)
**Path Parameters:**
| Parameter | Type | Required | Description |
|-----------|--------|----------|-------------------------------|
| `topic` | string | Yes | Topic identifier to query |
### Response
**Content-Type:** `application/json`
**Success (200 OK):**
```json
{
"sessions": [
{
"code": "550e8400-e29b-41d4-a716-446655440000",
"info": "peer-123",
"offer": "<SIGNALING_DATA>",
"offerCandidates": ["<SIGNALING_DATA>"],
"createdAt": 1699564800000,
"expiresAt": 1699565100000
},
{
"code": "660e8400-e29b-41d4-a716-446655440001",
"info": "peer-456",
"offer": "<SIGNALING_DATA>",
"offerCandidates": [],
"createdAt": 1699564850000,
"expiresAt": 1699565150000
}
]
}
```
**Notes:**
- Only returns sessions from the same origin as the request
- Only returns sessions that haven't been answered yet
- Sessions are ordered by creation time (newest first)
- Use the `info` field to avoid answering your own offers
### Example
```bash
curl -X GET http://localhost:3000/my-room/sessions \
-H "Origin: https://example.com"
```
---
## POST `/:topic/offer`
Announces peer availability and creates a new session for the specified topic. Returns a unique session code (UUID) for other peers to connect to.
### Request
**Headers:**
- `Content-Type: application/json`
- `Origin: https://example.com` (required)
**Path Parameters:**
| Parameter | Type | Required | Description |
|-----------|--------|----------|----------------------------------------------|
| `topic` | string | Yes | Topic identifier for grouping peers (max 256 characters) |
**Body Parameters:**
| Parameter | Type | Required | Description |
|-----------|--------|----------|----------------------------------------------|
| `info` | string | Yes | Peer identifier/metadata (max 1024 characters) |
| `offer` | string | Yes | Signaling data for peer connection |
### Response
**Content-Type:** `application/json`
**Success (200 OK):**
```json
{
"code": "550e8400-e29b-41d4-a716-446655440000"
}
```
Returns a unique UUID session code.
### Example
```bash
curl -X POST http://localhost:3000/my-room/offer \
-H "Content-Type: application/json" \
-H "Origin: https://example.com" \
-d '{
"info": "peer-123",
"offer": "<SIGNALING_DATA>"
}'
# Response:
# {"code":"550e8400-e29b-41d4-a716-446655440000"}
```
---
## POST `/answer`
Connects to an existing peer session by sending connection data or exchanging signaling information.
### Request
**Headers:**
- `Content-Type: application/json`
- `Origin: https://example.com` (required)
**Body Parameters:**
| Parameter | Type | Required | Description |
|-------------|--------|----------|----------------------------------------------------------|
| `code` | string | Yes | The session UUID from the offer |
| `answer` | string | No* | Response signaling data for connection establishment |
| `candidate` | string | No* | Additional signaling data for connection negotiation |
| `side` | string | Yes | Which peer is sending: `offerer` or `answerer` |
*Either `answer` or `candidate` must be provided, but not both.
### Response
**Content-Type:** `application/json`
**Success (200 OK):**
```json
{
"success": true
}
```
**Notes:**
- Origin header must match the session's origin
- Sessions are isolated by origin to group topics by domain
### Examples
**Sending connection response:**
```bash
curl -X POST http://localhost:3000/answer \
-H "Content-Type: application/json" \
-H "Origin: https://example.com" \
-d '{
"code": "550e8400-e29b-41d4-a716-446655440000",
"answer": "<SIGNALING_DATA>",
"side": "answerer"
}'
# Response:
# {"success":true}
```
**Sending additional signaling data:**
```bash
curl -X POST http://localhost:3000/answer \
-H "Content-Type: application/json" \
-H "Origin: https://example.com" \
-d '{
"code": "550e8400-e29b-41d4-a716-446655440000",
"candidate": "<SIGNALING_DATA>",
"side": "offerer"
}'
# Response:
# {"success":true}
```
---
## POST `/poll`
Retrieves session data including offers, responses, and signaling information from the other peer.
### Request
**Headers:**
- `Content-Type: application/json`
- `Origin: https://example.com` (required)
**Body Parameters:**
| Parameter | Type | Required | Description |
|-----------|--------|----------|-------------------------------------------------|
| `code` | string | Yes | The session UUID |
| `side` | string | Yes | Which side is polling: `offerer` or `answerer` |
### Response
**Content-Type:** `application/json`
**Success (200 OK):**
Response varies by side:
**For `side=offerer` (the offerer polls for response from answerer):**
```json
{
"answer": "<SIGNALING_DATA>",
"answerCandidates": [
"<SIGNALING_DATA_1>",
"<SIGNALING_DATA_2>"
]
}
```
**For `side=answerer` (the answerer polls for offer from offerer):**
```json
{
"offer": "<SIGNALING_DATA>",
"offerCandidates": [
"<SIGNALING_DATA_1>",
"<SIGNALING_DATA_2>"
]
}
```
**Notes:**
- `answer` will be `null` if the answerer hasn't responded yet
- Candidate arrays will be empty `[]` if no additional signaling data has been sent
- Use this endpoint for polling to check for new signaling data
- Origin header must match the session's origin
### Examples
**Answerer polling for signaling data:**
```bash
curl -X POST http://localhost:3000/poll \
-H "Content-Type: application/json" \
-H "Origin: https://example.com" \
-d '{
"code": "550e8400-e29b-41d4-a716-446655440000",
"side": "answerer"
}'
# Response:
# {
# "offer": "<SIGNALING_DATA>",
# "offerCandidates": ["<SIGNALING_DATA>"]
# }
```
**Offerer polling for response:**
```bash
curl -X POST http://localhost:3000/poll \
-H "Content-Type: application/json" \
-H "Origin: https://example.com" \
-d '{
"code": "550e8400-e29b-41d4-a716-446655440000",
"side": "offerer"
}'
# Response:
# {
# "answer": "<SIGNALING_DATA>",
# "answerCandidates": ["<SIGNALING_DATA>"]
# }
```
---
## GET `/health`
Health check endpoint.
### Response
**Content-Type:** `application/json`
**Success (200 OK):**
```json
{
"status": "ok",
"timestamp": 1699564800000
}
```
---
## Error Responses
All endpoints may return the following error responses:
**400 Bad Request:**
```json
{
"error": "Missing or invalid required parameter: topic"
}
```
**404 Not Found:**
```json
{
"error": "Session not found, expired, or origin mismatch"
}
```
**500 Internal Server Error:**
```json
{
"error": "Internal server error"
}
```
---
## Usage Flow
### Peer Discovery and Connection
1. **Check server version (optional):**
- GET `/` to see server version information
2. **Discover active topics:**
- GET `/topics` to see all topics and peer counts
- Optional: paginate through results with `?page=2&limit=100`
3. **Peer A announces availability:**
- POST `/:topic/offer` with peer identifier and signaling data
- Receives a unique session code
4. **Peer B discovers peers:**
- GET `/:topic/sessions` to list available sessions in a topic
- Filters out sessions with their own info to avoid self-connection
- Selects a peer to connect to
5. **Peer B initiates connection:**
- POST `/answer` with the session code and their signaling data
6. **Both peers exchange signaling information:**
- POST `/answer` with additional signaling data as needed
- POST `/poll` to retrieve signaling data from the other peer
7. **Peer connection established**
- Peers use exchanged signaling data to establish direct connection
- Session automatically expires after configured timeout

279
README.md
View File

@@ -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)
🌐 **Topic-based peer discovery and WebRTC signaling**
🌐 **DNS-like WebRTC signaling with username claiming and service discovery**
Scalable peer-to-peer connection establishment with topic-based discovery, stateless authentication, and complete WebRTC signaling.
Scalable WebRTC signaling server with cryptographic username claiming, service publishing, and privacy-preserving discovery.
**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,28 @@ Scalable peer-to-peer connection establishment with topic-based discovery, state
## Features
- **Topic-Based Discovery**: Tag offers with topics (e.g., torrent infohashes) for efficient peer finding
- **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
- **Protected Offers**: Optional secret field for access-controlled peer connections
- **Bloom Filters**: Client-side peer exclusion for efficient discovery
- **Multi-Offer Support**: Create multiple offers per peer simultaneously
- **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 → receives UUID abc123
bob queries alice's services → gets UUID abc123
bob connects to UUID abc123 → WebRTC connection established
```
## Quick Start
**Node.js:**
@@ -32,7 +46,7 @@ npm install && npm start
**Docker:**
```bash
docker build -t rondevu . && docker run -p 3000:3000 -e STORAGE_PATH=:memory: rondevu
docker build -t rondevu . && docker run -p 3000:3000 -e STORAGE_PATH=:memory: -e AUTH_SECRET=$(openssl rand -hex 32) rondevu
```
**Cloudflare Workers:**
@@ -53,6 +67,8 @@ 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:**
```json
{
@@ -61,65 +77,172 @@ Register a new peer and receive credentials (peerId + secret)
}
```
#### `GET /topics?limit=50&offset=0`
List all topics with active peer counts (paginated)
### Username Management
**Query Parameters:**
- `limit` (optional): Maximum number of topics to return (default: 50, max: 200)
- `offset` (optional): Number of topics to skip (default: 0)
#### `POST /usernames/claim`
Claim a username with cryptographic proof
**Response:**
**Request:**
```json
{
"topics": [
{"topic": "movie-xyz", "activePeers": 42},
{"topic": "torrent-abc", "activePeers": 15}
],
"total": 123,
"limit": 50,
"offset": 0
"username": "alice",
"publicKey": "base64-encoded-ed25519-public-key",
"signature": "base64-encoded-signature",
"message": "claim:alice:1733404800000"
}
```
#### `GET /offers/by-topic/:topic?limit=50&bloom=...`
Find offers by topic with optional bloom filter exclusion
**Response:**
```json
{
"username": "alice",
"claimedAt": 1733404800000,
"expiresAt": 1765027200000
}
```
**Query Parameters:**
- `limit` (optional): Maximum offers to return (default: 50, max: 200)
- `bloom` (optional): Base64-encoded bloom filter to exclude known peers
**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 /usernames/:username`
Check username availability and claim status
**Response:**
```json
{
"topic": "movie-xyz",
"offers": [
"username": "alice",
"available": false,
"claimedAt": 1733404800000,
"expiresAt": 1765027200000,
"publicKey": "..."
}
```
#### `GET /usernames/:username/services`
List all services for a username (privacy-preserving)
**Response:**
```json
{
"id": "offer-id",
"peerId": "peer-id",
"username": "alice",
"services": [
{
"uuid": "abc123",
"isPublic": false
},
{
"uuid": "def456",
"isPublic": true,
"serviceFqn": "com.example.public@1.0.0",
"metadata": { "description": "Public service" }
}
]
}
```
### Service Management
#### `POST /services`
Publish a service (requires authentication and username signature)
**Headers:**
- `Authorization: Bearer {peerId}:{secret}`
**Request:**
```json
{
"username": "alice",
"serviceFqn": "com.example.chat@1.0.0",
"sdp": "v=0...",
"topics": ["movie-xyz", "hd-content"],
"expiresAt": 1234567890,
"lastSeen": 1234567890,
"hasSecret": true // Indicates if secret is required to answer
}
],
"total": 42,
"returned": 10
"ttl": 300000,
"isPublic": false,
"metadata": { "description": "Chat service" },
"signature": "base64-encoded-signature",
"message": "publish:alice:com.example.chat@1.0.0:1733404800000"
}
```
**Notes:**
- `hasSecret`: Boolean flag indicating whether a secret is required to answer this offer. The actual secret is never exposed in public endpoints.
**Response:**
```json
{
"serviceId": "uuid-v4",
"uuid": "uuid-v4-for-index",
"offerId": "offer-hash-id",
"expiresAt": 1733405100000
}
```
#### `GET /peers/:peerId/offers`
View all offers from a specific peer
**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`)
### Authenticated Endpoints
**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.-]+)?$`
All authenticated endpoints require `Authorization: Bearer {peerId}:{secret}` header.
#### `GET /services/:uuid`
Get service details by UUID
**Response:**
```json
{
"serviceId": "...",
"username": "alice",
"serviceFqn": "com.example.chat@1.0.0",
"offerId": "...",
"sdp": "v=0...",
"isPublic": false,
"metadata": { ... },
"createdAt": 1733404800000,
"expiresAt": 1733405100000
}
```
#### `DELETE /services/:serviceId`
Unpublish a service (requires authentication and ownership)
**Headers:**
- `Authorization: Bearer {peerId}:{secret}`
**Request:**
```json
{
"username": "alice"
}
```
### Service Discovery
#### `POST /index/:username/query`
Query a service by FQN
**Request:**
```json
{
"serviceFqn": "com.example.chat@1.0.0"
}
```
**Response:**
```json
{
"uuid": "abc123",
"allowed": true
}
```
### Offer Management (Low-level)
#### `POST /offers`
Create one or more offers
Create one or more offers (requires authentication)
**Headers:**
- `Authorization: Bearer {peerId}:{secret}`
**Request:**
```json
@@ -127,17 +250,12 @@ Create one or more offers
"offers": [
{
"sdp": "v=0...",
"topics": ["movie-xyz", "hd-content"],
"ttl": 300000,
"secret": "my-secret-password" // Optional: protect offer (max 128 chars)
"ttl": 300000
}
]
}
```
**Notes:**
- `secret` (optional): Protect the offer with a secret. Answerers must provide the correct secret to connect.
#### `GET /offers/mine`
List all offers owned by authenticated peer
@@ -153,14 +271,10 @@ Answer an offer (locks it to answerer)
**Request:**
```json
{
"sdp": "v=0...",
"secret": "my-secret-password" // Required if offer is protected
"sdp": "v=0..."
}
```
**Notes:**
- `secret` (optional): Required if the offer was created with a secret. Must match the offer's secret.
#### `GET /offers/answers`
Poll for answers to your offers
@@ -186,13 +300,62 @@ Environment variables:
| `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` | `0.4.0` | Server version (semver) |
| `AUTH_SECRET` | Random 32-byte hex | Secret key for credential encryption |
| `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 |
| `MAX_TOPICS_PER_OFFER` | `20` | Maximum topics per offer |
## Database Schema
### usernames
- `username` (PK): Claimed username
- `public_key`: Ed25519 public key (base64)
- `claimed_at`: Claim timestamp
- `expires_at`: Expiry timestamp (365 days)
- `last_used`: Last activity timestamp
- `metadata`: Optional JSON metadata
### services
- `id` (PK): Service ID (UUID)
- `username` (FK): Owner username
- `service_fqn`: Fully qualified name (com.example.chat@1.0.0)
- `offer_id` (FK): WebRTC offer ID
- `is_public`: Public/private flag
- `metadata`: JSON metadata
- `created_at`, `expires_at`: Timestamps
### service_index (privacy layer)
- `uuid` (PK): Random UUID for discovery
- `service_id` (FK): Links to service
- `username`, `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](../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

View File

@@ -0,0 +1,83 @@
-- V2 Migration: Add offers, usernames, and services tables
-- Offers table (replaces sessions)
CREATE TABLE IF NOT EXISTS offers (
id TEXT PRIMARY KEY,
peer_id TEXT NOT NULL,
sdp TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
last_seen INTEGER NOT NULL,
secret TEXT,
answerer_peer_id TEXT,
answer_sdp TEXT,
answered_at INTEGER
);
CREATE INDEX IF NOT EXISTS idx_offers_peer ON offers(peer_id);
CREATE INDEX IF NOT EXISTS idx_offers_expires ON offers(expires_at);
CREATE INDEX IF NOT EXISTS idx_offers_last_seen ON offers(last_seen);
CREATE INDEX IF NOT EXISTS idx_offers_answerer ON offers(answerer_peer_id);
-- ICE candidates table
CREATE TABLE IF NOT EXISTS ice_candidates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
offer_id TEXT NOT NULL,
peer_id TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('offerer', 'answerer')),
candidate TEXT NOT NULL,
created_at INTEGER NOT NULL,
FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_ice_offer ON ice_candidates(offer_id);
CREATE INDEX IF NOT EXISTS idx_ice_peer ON ice_candidates(peer_id);
CREATE INDEX IF NOT EXISTS idx_ice_created ON ice_candidates(created_at);
-- Usernames table
CREATE TABLE IF NOT EXISTS usernames (
username TEXT PRIMARY KEY,
public_key TEXT NOT NULL UNIQUE,
claimed_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
last_used INTEGER NOT NULL,
metadata TEXT,
CHECK(length(username) >= 3 AND length(username) <= 32)
);
CREATE INDEX IF NOT EXISTS idx_usernames_expires ON usernames(expires_at);
CREATE INDEX IF NOT EXISTS idx_usernames_public_key ON usernames(public_key);
-- Services table
CREATE TABLE IF NOT EXISTS services (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
service_fqn TEXT NOT NULL,
offer_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
is_public INTEGER NOT NULL DEFAULT 0,
metadata TEXT,
FOREIGN KEY (username) REFERENCES usernames(username) ON DELETE CASCADE,
FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE,
UNIQUE(username, service_fqn)
);
CREATE INDEX IF NOT EXISTS idx_services_username ON services(username);
CREATE INDEX IF NOT EXISTS idx_services_fqn ON services(service_fqn);
CREATE INDEX IF NOT EXISTS idx_services_expires ON services(expires_at);
CREATE INDEX IF NOT EXISTS idx_services_offer ON services(offer_id);
-- Service index table (privacy layer)
CREATE TABLE IF NOT EXISTS service_index (
uuid TEXT PRIMARY KEY,
service_id TEXT NOT NULL,
username TEXT NOT NULL,
service_fqn TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_service_index_username ON service_index(username);
CREATE INDEX IF NOT EXISTS idx_service_index_expires ON service_index(expires_at);

14
package-lock.json generated
View File

@@ -1,14 +1,15 @@
{
"name": "@xtr-dev/rondevu-server",
"version": "0.1.2",
"version": "0.1.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@xtr-dev/rondevu-server",
"version": "0.1.2",
"version": "0.1.5",
"dependencies": {
"@hono/node-server": "^1.19.6",
"@noble/ed25519": "^3.0.0",
"better-sqlite3": "^12.4.1",
"hono": "^4.10.4"
},
@@ -523,6 +524,15 @@
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@noble/ed25519": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-3.0.0.tgz",
"integrity": "sha512-QyteqMNm0GLqfa5SoYbSC3+Pvykwpn95Zgth4MFVSMKBB75ELl9tX1LAVsN4c3HXOrakHsF2gL4zWDAYCcsnzg==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@tsconfig/node10": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",

View File

@@ -1,7 +1,7 @@
{
"name": "@xtr-dev/rondevu-server",
"version": "0.1.2",
"description": "Topic-based peer discovery and signaling server for distributed P2P applications",
"version": "0.2.4",
"description": "DNS-like WebRTC signaling server with username claiming and service discovery",
"main": "dist/index.js",
"scripts": {
"build": "node build.js",
@@ -21,6 +21,7 @@
},
"dependencies": {
"@hono/node-server": "^1.19.6",
"@noble/ed25519": "^3.0.0",
"better-sqlite3": "^12.4.1",
"hono": "^4.10.4"
}

View File

@@ -3,12 +3,11 @@ import { cors } from 'hono/cors';
import { Storage } from './storage/types.ts';
import { Config } from './config.ts';
import { createAuthMiddleware, getAuthenticatedPeerId } from './middleware/auth.ts';
import { generatePeerId, encryptPeerId } from './crypto.ts';
import { parseBloomFilter } from './bloom.ts';
import { generatePeerId, encryptPeerId, validateUsernameClaim, validateServicePublish, validateServiceFqn } from './crypto.ts';
import type { Context } from 'hono';
/**
* Creates the Hono application with topic-based WebRTC signaling endpoints
* Creates the Hono application with username and service-based WebRTC signaling
*/
export function createApp(storage: Storage, config: Config) {
const app = new Hono();
@@ -16,18 +15,15 @@ export function createApp(storage: Storage, config: Config) {
// Create auth middleware
const authMiddleware = createAuthMiddleware(config.authSecret);
// Enable CORS with dynamic origin handling
// Enable CORS
app.use('/*', cors({
origin: (origin) => {
// If no origin restrictions (wildcard), allow any origin
if (config.corsOrigins.length === 1 && config.corsOrigins[0] === '*') {
return origin;
}
// Otherwise check if origin is in allowed list
if (config.corsOrigins.includes(origin)) {
return origin;
}
// Default to first allowed origin
return config.corsOrigins[0];
},
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
@@ -37,21 +33,23 @@ export function createApp(storage: Storage, config: Config) {
credentials: true,
}));
// ===== General Endpoints =====
/**
* GET /
* Returns server version information
* Returns server information
*/
app.get('/', (c) => {
return c.json({
version: config.version,
name: 'Rondevu',
description: 'Topic-based peer discovery and signaling server'
description: 'DNS-like WebRTC signaling with username claiming and service discovery'
});
});
/**
* GET /health
* Health check endpoint with version
* Health check endpoint
*/
app.get('/health', (c) => {
return c.json({
@@ -63,14 +61,11 @@ export function createApp(storage: Storage, config: Config) {
/**
* POST /register
* Register a new peer and receive credentials
* Register a new peer
*/
app.post('/register', async (c) => {
try {
// Generate new peer ID
const peerId = generatePeerId();
// Encrypt peer ID with server secret (async operation)
const secret = await encryptPeerId(peerId, config.authSecret);
return c.json({
@@ -83,10 +78,330 @@ export function createApp(storage: Storage, config: Config) {
}
});
// ===== Username Management =====
/**
* POST /usernames/claim
* Claim a username with cryptographic proof
*/
app.post('/usernames/claim', async (c) => {
try {
const body = await c.req.json();
const { username, publicKey, signature, message } = body;
if (!username || !publicKey || !signature || !message) {
return c.json({ error: 'Missing required parameters: username, 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
}, 200);
} 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);
}
});
/**
* GET /usernames/:username
* Check if username is available or get claim info
*/
app.get('/usernames/: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);
}
});
/**
* GET /usernames/:username/services
* List services for a username (privacy-preserving)
*/
app.get('/usernames/:username/services', async (c) => {
try {
const username = c.req.param('username');
const services = await storage.listServicesForUsername(username);
return c.json({
username,
services
}, 200);
} catch (err) {
console.error('Error listing services:', err);
return c.json({ error: 'Internal server error' }, 500);
}
});
// ===== Service Management =====
/**
* POST /services
* Publish a service
*/
app.post('/services', authMiddleware, async (c) => {
let username: string | undefined;
let serviceFqn: string | undefined;
let offers: any[] = [];
try {
const body = await c.req.json();
({ username, serviceFqn } = body);
const { sdp, ttl, isPublic, metadata, signature, message } = body;
if (!username || !serviceFqn || !sdp) {
return c.json({ error: 'Missing required parameters: username, serviceFqn, sdp' }, 400);
}
// Validate service FQN
const fqnValidation = validateServiceFqn(serviceFqn);
if (!fqnValidation.valid) {
return c.json({ error: fqnValidation.error }, 400);
}
// 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);
}
// Delete existing service if one exists (upsert behavior)
const existingUuid = await storage.queryService(username, serviceFqn);
if (existingUuid) {
const existingService = await storage.getServiceByUuid(existingUuid);
if (existingService) {
await storage.deleteService(existingService.id, username);
}
}
// Validate SDP
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);
}
// Calculate expiry
const peerId = getAuthenticatedPeerId(c);
const offerTtl = Math.min(
Math.max(ttl || config.offerDefaultTtl, config.offerMinTtl),
config.offerMaxTtl
);
const expiresAt = Date.now() + offerTtl;
// Create offer first
offers = await storage.createOffers([{
peerId,
sdp,
expiresAt
}]);
if (offers.length === 0) {
return c.json({ error: 'Failed to create offer' }, 500);
}
const offer = offers[0];
// Create service
const result = await storage.createService({
username,
serviceFqn,
offerId: offer.id,
expiresAt,
isPublic: isPublic || false,
metadata: metadata ? JSON.stringify(metadata) : undefined
});
return c.json({
serviceId: result.service.id,
uuid: result.indexUuid,
offerId: offer.id,
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,
username,
serviceFqn,
offerId: offers[0]?.id
});
return c.json({
error: 'Internal server error',
details: (err as Error).message
}, 500);
}
});
/**
* GET /services/:uuid
* Get service details by index UUID
* Returns an available (unanswered) offer from the service's pool
*/
app.get('/services/:uuid', async (c) => {
try {
const uuid = c.req.param('uuid');
const service = await storage.getServiceByUuid(uuid);
if (!service) {
return c.json({ error: 'Service not found' }, 404);
}
// Get the initial offer to find the peer ID
const initialOffer = await storage.getOfferById(service.offerId);
if (!initialOffer) {
return c.json({ error: 'Associated offer not found' }, 404);
}
// Get all offers from this peer
const peerOffers = await storage.getOffersByPeerId(initialOffer.peerId);
// Find an unanswered offer
const availableOffer = peerOffers.find(offer => !offer.answererPeerId);
if (!availableOffer) {
return c.json({
error: 'No available offers',
message: 'All offers from this service are currently in use. Please try again later.'
}, 503);
}
return c.json({
serviceId: service.id,
username: service.username,
serviceFqn: service.serviceFqn,
offerId: availableOffer.id,
sdp: availableOffer.sdp,
isPublic: service.isPublic,
metadata: service.metadata ? JSON.parse(service.metadata) : undefined,
createdAt: service.createdAt,
expiresAt: service.expiresAt
}, 200);
} catch (err) {
console.error('Error getting service:', err);
return c.json({ error: 'Internal server error' }, 500);
}
});
/**
* DELETE /services/:serviceId
* Delete a service (requires ownership)
*/
app.delete('/services/:serviceId', authMiddleware, async (c) => {
try {
const serviceId = c.req.param('serviceId');
const body = await c.req.json();
const { username } = body;
if (!username) {
return c.json({ error: 'Missing required parameter: username' }, 400);
}
const deleted = await storage.deleteService(serviceId, 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);
}
});
/**
* POST /index/:username/query
* Query service by FQN (returns UUID)
*/
app.post('/index/:username/query', async (c) => {
try {
const username = c.req.param('username');
const body = await c.req.json();
const { serviceFqn } = body;
if (!serviceFqn) {
return c.json({ error: 'Missing required parameter: serviceFqn' }, 400);
}
const uuid = await storage.queryService(username, serviceFqn);
if (!uuid) {
return c.json({ error: 'Service not found' }, 404);
}
return c.json({
uuid,
allowed: true
}, 200);
} catch (err) {
console.error('Error querying service:', err);
return c.json({ error: 'Internal server error' }, 500);
}
});
// ===== Offer Management (Core WebRTC) =====
/**
* POST /offers
* Creates one or more offers with topics
* Requires authentication
* Create offers (direct, no service - for testing/advanced users)
*/
app.post('/offers', authMiddleware, async (c) => {
try {
@@ -98,217 +413,56 @@ export function createApp(storage: Storage, config: Config) {
}
if (offers.length > config.maxOffersPerRequest) {
return c.json({ error: `Too many offers. Maximum ${config.maxOffersPerRequest} per request` }, 400);
return c.json({ error: `Too many offers (max ${config.maxOffersPerRequest})` }, 400);
}
const peerId = getAuthenticatedPeerId(c);
// Validate and prepare offers
const offerRequests = [];
for (const offer of offers) {
// Validate SDP
if (!offer.sdp || typeof offer.sdp !== 'string') {
return c.json({ error: 'Each offer must have an sdp field' }, 400);
const validated = offers.map((offer: any) => {
const { sdp, ttl, secret } = offer;
if (typeof sdp !== 'string' || sdp.length === 0) {
throw new Error('Invalid SDP in offer');
}
if (offer.sdp.length > 65536) {
return c.json({ error: 'SDP must be 64KB or less' }, 400);
if (sdp.length > 64 * 1024) {
throw new Error('SDP too large (max 64KB)');
}
// Validate secret if provided
if (offer.secret !== undefined) {
if (typeof offer.secret !== 'string') {
return c.json({ error: 'Secret must be a string' }, 400);
}
if (offer.secret.length > 128) {
return c.json({ error: 'Secret must be 128 characters or less' }, 400);
}
}
const offerTtl = Math.min(
Math.max(ttl || config.offerDefaultTtl, config.offerMinTtl),
config.offerMaxTtl
);
// Validate topics
if (!Array.isArray(offer.topics) || offer.topics.length === 0) {
return c.json({ error: 'Each offer must have a non-empty topics array' }, 400);
}
return {
peerId,
sdp,
expiresAt: Date.now() + offerTtl,
secret: secret ? String(secret).substring(0, 128) : undefined
};
});
if (offer.topics.length > config.maxTopicsPerOffer) {
return c.json({ error: `Too many topics. Maximum ${config.maxTopicsPerOffer} per offer` }, 400);
}
const created = await storage.createOffers(validated);
for (const topic of offer.topics) {
if (typeof topic !== 'string' || topic.length === 0 || topic.length > 256) {
return c.json({ error: 'Each topic must be a string between 1 and 256 characters' }, 400);
}
}
// Validate and clamp TTL
let ttl = offer.ttl || config.offerDefaultTtl;
if (ttl < config.offerMinTtl) {
ttl = config.offerMinTtl;
}
if (ttl > config.offerMaxTtl) {
ttl = config.offerMaxTtl;
}
offerRequests.push({
return c.json({
offers: created.map(offer => ({
id: offer.id,
peerId,
sdp: offer.sdp,
topics: offer.topics,
expiresAt: Date.now() + ttl,
secret: offer.secret,
});
}
// Create offers
const createdOffers = await storage.createOffers(offerRequests);
// Return simplified response
return c.json({
offers: createdOffers.map(o => ({
id: o.id,
peerId: o.peerId,
topics: o.topics,
expiresAt: o.expiresAt
peerId: offer.peerId,
expiresAt: offer.expiresAt,
createdAt: offer.createdAt,
hasSecret: !!offer.secret
}))
}, 200);
} catch (err) {
}, 201);
} catch (err: any) {
console.error('Error creating offers:', err);
return c.json({ error: 'Internal server error' }, 500);
}
});
/**
* GET /offers/by-topic/:topic
* Find offers by topic with optional bloom filter exclusion
* Public endpoint (no auth required)
*/
app.get('/offers/by-topic/:topic', async (c) => {
try {
const topic = c.req.param('topic');
const bloomParam = c.req.query('bloom');
const limitParam = c.req.query('limit');
const limit = limitParam ? Math.min(parseInt(limitParam, 10), 200) : 50;
// Parse bloom filter if provided
let excludePeerIds: string[] = [];
if (bloomParam) {
const bloom = parseBloomFilter(bloomParam);
if (!bloom) {
return c.json({ error: 'Invalid bloom filter format' }, 400);
}
// Get all offers for topic first
const allOffers = await storage.getOffersByTopic(topic);
// Test each peer ID against bloom filter
const excludeSet = new Set<string>();
for (const offer of allOffers) {
if (bloom.test(offer.peerId)) {
excludeSet.add(offer.peerId);
}
}
excludePeerIds = Array.from(excludeSet);
}
// Get filtered offers
let offers = await storage.getOffersByTopic(topic, excludePeerIds.length > 0 ? excludePeerIds : undefined);
// Apply limit
const total = offers.length;
offers = offers.slice(0, limit);
return c.json({
topic,
offers: offers.map(o => ({
id: o.id,
peerId: o.peerId,
sdp: o.sdp,
topics: o.topics,
expiresAt: o.expiresAt,
lastSeen: o.lastSeen,
hasSecret: !!o.secret // Indicate if secret is required without exposing it
})),
total: bloomParam ? total + excludePeerIds.length : total,
returned: offers.length
}, 200);
} catch (err) {
console.error('Error fetching offers by topic:', err);
return c.json({ error: 'Internal server error' }, 500);
}
});
/**
* GET /topics
* List all topics with active peer counts (paginated)
* Public endpoint (no auth required)
* Query params:
* - limit: Max topics to return (default 50, max 200)
* - offset: Number of topics to skip (default 0)
* - startsWith: Filter topics starting with this prefix (optional)
*/
app.get('/topics', async (c) => {
try {
const limitParam = c.req.query('limit');
const offsetParam = c.req.query('offset');
const startsWithParam = c.req.query('startsWith');
const limit = limitParam ? Math.min(parseInt(limitParam, 10), 200) : 50;
const offset = offsetParam ? parseInt(offsetParam, 10) : 0;
const startsWith = startsWithParam || undefined;
const result = await storage.getTopics(limit, offset, startsWith);
return c.json({
topics: result.topics,
total: result.total,
limit,
offset,
...(startsWith && { startsWith })
}, 200);
} catch (err) {
console.error('Error fetching topics:', err);
return c.json({ error: 'Internal server error' }, 500);
}
});
/**
* GET /peers/:peerId/offers
* View all offers from a specific peer
* Public endpoint
*/
app.get('/peers/:peerId/offers', async (c) => {
try {
const peerId = c.req.param('peerId');
const offers = await storage.getOffersByPeerId(peerId);
// Collect unique topics
const topicsSet = new Set<string>();
offers.forEach(o => o.topics.forEach(t => topicsSet.add(t)));
return c.json({
peerId,
offers: offers.map(o => ({
id: o.id,
sdp: o.sdp,
topics: o.topics,
expiresAt: o.expiresAt,
lastSeen: o.lastSeen,
hasSecret: !!o.secret // Indicate if secret is required without exposing it
})),
topics: Array.from(topicsSet)
}, 200);
} catch (err) {
console.error('Error fetching peer offers:', err);
return c.json({ error: 'Internal server error' }, 500);
return c.json({ error: err.message || 'Internal server error' }, 500);
}
});
/**
* GET /offers/mine
* List all offers owned by authenticated peer
* Requires authentication
* Get authenticated peer's offers
*/
app.get('/offers/mine', authMiddleware, async (c) => {
try {
@@ -316,29 +470,26 @@ export function createApp(storage: Storage, config: Config) {
const offers = await storage.getOffersByPeerId(peerId);
return c.json({
peerId,
offers: offers.map(o => ({
id: o.id,
sdp: o.sdp,
topics: o.topics,
createdAt: o.createdAt,
expiresAt: o.expiresAt,
lastSeen: o.lastSeen,
secret: o.secret, // Owner can see the secret
answererPeerId: o.answererPeerId,
answeredAt: o.answeredAt
offers: offers.map(offer => ({
id: offer.id,
sdp: offer.sdp,
createdAt: offer.createdAt,
expiresAt: offer.expiresAt,
lastSeen: offer.lastSeen,
hasSecret: !!offer.secret,
answererPeerId: offer.answererPeerId,
answered: !!offer.answererPeerId
}))
}, 200);
} catch (err) {
console.error('Error fetching own offers:', err);
console.error('Error getting offers:', err);
return c.json({ error: 'Internal server error' }, 500);
}
});
/**
* DELETE /offers/:offerId
* Delete a specific offer
* Requires authentication and ownership
* Delete an offer
*/
app.delete('/offers/:offerId', authMiddleware, async (c) => {
try {
@@ -348,10 +499,10 @@ export function createApp(storage: Storage, config: Config) {
const deleted = await storage.deleteOffer(offerId, peerId);
if (!deleted) {
return c.json({ error: 'Offer not found or not authorized' }, 404);
return c.json({ error: 'Offer not found or not owned by this peer' }, 404);
}
return c.json({ deleted: true }, 200);
return c.json({ success: true }, 200);
} catch (err) {
console.error('Error deleting offer:', err);
return c.json({ error: 'Internal server error' }, 500);
@@ -360,40 +511,35 @@ export function createApp(storage: Storage, config: Config) {
/**
* POST /offers/:offerId/answer
* Answer a specific offer (locks it to answerer)
* Requires authentication
* Answer an offer
*/
app.post('/offers/:offerId/answer', authMiddleware, async (c) => {
try {
const offerId = c.req.param('offerId');
const peerId = getAuthenticatedPeerId(c);
const body = await c.req.json();
const { sdp, secret } = body;
if (!sdp || typeof sdp !== 'string') {
return c.json({ error: 'Missing or invalid required parameter: sdp' }, 400);
if (!sdp) {
return c.json({ error: 'Missing required parameter: sdp' }, 400);
}
if (sdp.length > 65536) {
return c.json({ error: 'SDP must be 64KB or less' }, 400);
if (typeof sdp !== 'string' || sdp.length === 0) {
return c.json({ error: 'Invalid SDP' }, 400);
}
// Validate secret if provided
if (secret !== undefined && typeof secret !== 'string') {
return c.json({ error: 'Secret must be a string' }, 400);
if (sdp.length > 64 * 1024) {
return c.json({ error: 'SDP too large (max 64KB)' }, 400);
}
const result = await storage.answerOffer(offerId, peerId, sdp, secret);
const answererPeerId = getAuthenticatedPeerId(c);
const result = await storage.answerOffer(offerId, answererPeerId, sdp, secret);
if (!result.success) {
return c.json({ error: result.error }, 400);
}
return c.json({
offerId,
answererId: peerId,
answeredAt: Date.now()
}, 200);
return c.json({ success: true }, 200);
} catch (err) {
console.error('Error answering offer:', err);
return c.json({ error: 'Internal server error' }, 500);
@@ -402,8 +548,7 @@ export function createApp(storage: Storage, config: Config) {
/**
* GET /offers/answers
* Poll for answers to all of authenticated peer's offers
* Requires authentication (offerer)
* Get answers for authenticated peer's offers
*/
app.get('/offers/answers', authMiddleware, async (c) => {
try {
@@ -411,57 +556,49 @@ export function createApp(storage: Storage, config: Config) {
const offers = await storage.getAnsweredOffers(peerId);
return c.json({
answers: offers.map(o => ({
offerId: o.id,
answererId: o.answererPeerId,
sdp: o.answerSdp,
answeredAt: o.answeredAt,
topics: o.topics
answers: offers.map(offer => ({
offerId: offer.id,
answererId: offer.answererPeerId,
sdp: offer.answerSdp,
answeredAt: offer.answeredAt
}))
}, 200);
} catch (err) {
console.error('Error fetching answers:', err);
console.error('Error getting answers:', err);
return c.json({ error: 'Internal server error' }, 500);
}
});
// ===== ICE Candidate Exchange =====
/**
* POST /offers/:offerId/ice-candidates
* Post ICE candidates for an offer
* Requires authentication (must be offerer or answerer)
* Add ICE candidates for an offer
*/
app.post('/offers/:offerId/ice-candidates', authMiddleware, async (c) => {
try {
const offerId = c.req.param('offerId');
const peerId = getAuthenticatedPeerId(c);
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 (must be non-empty array)' }, 400);
return c.json({ error: 'Missing or invalid required parameter: candidates' }, 400);
}
// Verify offer exists and caller is offerer or answerer
const peerId = getAuthenticatedPeerId(c);
// Get offer to determine role
const offer = await storage.getOfferById(offerId);
if (!offer) {
return c.json({ error: 'Offer not found or expired' }, 404);
return c.json({ error: 'Offer not found' }, 404);
}
let role: 'offerer' | 'answerer';
if (offer.peerId === peerId) {
role = 'offerer';
} else if (offer.answererPeerId === peerId) {
role = 'answerer';
} else {
return c.json({ error: 'Not authorized to post ICE candidates for this offer' }, 403);
}
// Determine role
const role = offer.peerId === peerId ? 'offerer' : 'answerer';
const added = await storage.addIceCandidates(offerId, peerId, role, candidates);
const count = await storage.addIceCandidates(offerId, peerId, role, candidates);
return c.json({
offerId,
candidatesAdded: added
}, 200);
return c.json({ count }, 200);
} catch (err) {
console.error('Error adding ICE candidates:', err);
return c.json({ error: 'Internal server error' }, 500);
@@ -470,50 +607,34 @@ export function createApp(storage: Storage, config: Config) {
/**
* GET /offers/:offerId/ice-candidates
* Poll for ICE candidates from the other peer
* Requires authentication (must be offerer or answerer)
* Get ICE candidates for an offer
*/
app.get('/offers/:offerId/ice-candidates', authMiddleware, async (c) => {
try {
const offerId = c.req.param('offerId');
const since = c.req.query('since');
const peerId = getAuthenticatedPeerId(c);
const sinceParam = c.req.query('since');
const since = sinceParam ? parseInt(sinceParam, 10) : undefined;
// Verify offer exists and caller is offerer or answerer
// Get offer to determine role
const offer = await storage.getOfferById(offerId);
if (!offer) {
return c.json({ error: 'Offer not found or expired' }, 404);
return c.json({ error: 'Offer not found' }, 404);
}
let targetRole: 'offerer' | 'answerer';
if (offer.peerId === peerId) {
// Offerer wants answerer's candidates
targetRole = 'answerer';
console.log(`[ICE GET] Offerer ${peerId} requesting answerer ICE candidates for offer ${offerId}, since=${since}, answererPeerId=${offer.answererPeerId}`);
} else if (offer.answererPeerId === peerId) {
// Answerer wants offerer's candidates
targetRole = 'offerer';
console.log(`[ICE GET] Answerer ${peerId} requesting offerer ICE candidates for offer ${offerId}, since=${since}, offererPeerId=${offer.peerId}`);
} else {
return c.json({ error: 'Not authorized to view ICE candidates for this offer' }, 403);
}
// Get candidates for opposite role
const targetRole = offer.peerId === peerId ? 'answerer' : 'offerer';
const sinceTimestamp = since ? parseInt(since, 10) : undefined;
const candidates = await storage.getIceCandidates(offerId, targetRole, since);
console.log(`[ICE GET] Found ${candidates.length} candidates for offer ${offerId}, targetRole=${targetRole}, since=${since}`);
const candidates = await storage.getIceCandidates(offerId, targetRole, sinceTimestamp);
return c.json({
offerId,
candidates: candidates.map(c => ({
candidate: c.candidate,
peerId: c.peerId,
role: c.role,
createdAt: c.createdAt
}))
}, 200);
} catch (err) {
console.error('Error fetching ICE candidates:', err);
console.error('Error getting ICE candidates:', err);
return c.json({ error: 'Internal server error' }, 500);
}
});

View File

@@ -1,66 +0,0 @@
/**
* Bloom filter utility for testing if peer IDs might be in a set
* Used to filter out known peers from discovery results
*/
export class BloomFilter {
private bits: Uint8Array;
private size: number;
private numHashes: number;
/**
* Creates a bloom filter from a base64 encoded bit array
*/
constructor(base64Data: string, numHashes: number = 3) {
// Decode base64 to Uint8Array (works in both Node.js and Workers)
const binaryString = atob(base64Data);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
this.bits = bytes;
this.size = this.bits.length * 8;
this.numHashes = numHashes;
}
/**
* Test if a peer ID might be in the filter
* Returns true if possibly in set, false if definitely not in set
*/
test(peerId: string): boolean {
for (let i = 0; i < this.numHashes; i++) {
const hash = this.hash(peerId, i);
const index = hash % this.size;
const byteIndex = Math.floor(index / 8);
const bitIndex = index % 8;
if (!(this.bits[byteIndex] & (1 << bitIndex))) {
return false;
}
}
return true;
}
/**
* Simple hash function (FNV-1a variant)
*/
private hash(str: string, seed: number): number {
let hash = 2166136261 ^ seed;
for (let i = 0; i < str.length; i++) {
hash ^= str.charCodeAt(i);
hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24);
}
return hash >>> 0;
}
}
/**
* Helper to parse bloom filter from base64 string
*/
export function parseBloomFilter(base64: string): BloomFilter | null {
try {
return new BloomFilter(base64);
} catch {
return null;
}
}

View File

@@ -16,7 +16,6 @@ export interface Config {
offerMinTtl: number;
cleanupInterval: number;
maxOffersPerRequest: number;
maxTopicsPerOffer: number;
}
/**
@@ -45,7 +44,6 @@ export function loadConfig(): Config {
offerMaxTtl: parseInt(process.env.OFFER_MAX_TTL || '86400000', 10),
offerMinTtl: parseInt(process.env.OFFER_MIN_TTL || '60000', 10),
cleanupInterval: parseInt(process.env.CLEANUP_INTERVAL || '60000', 10),
maxOffersPerRequest: parseInt(process.env.MAX_OFFERS_PER_REQUEST || '100', 10),
maxTopicsPerOffer: parseInt(process.env.MAX_TOPICS_PER_OFFER || '50', 10),
maxOffersPerRequest: parseInt(process.env.MAX_OFFERS_PER_REQUEST || '100', 10)
};
}

View File

@@ -1,12 +1,29 @@
/**
* Crypto utilities for stateless peer authentication
* Uses Web Crypto API for compatibility with both Node.js and Cloudflare Workers
* Uses @noble/ed25519 for Ed25519 signature verification
*/
import * as ed25519 from '@noble/ed25519';
// Set SHA-512 hash function for ed25519 (required in @noble/ed25519 v3+)
// Uses Web Crypto API (compatible with both Node.js and Cloudflare Workers)
ed25519.hashes.sha512Async = async (message: Uint8Array) => {
return new Uint8Array(await crypto.subtle.digest('SHA-512', message as BufferSource));
};
const ALGORITHM = 'AES-GCM';
const IV_LENGTH = 12; // 96 bits for GCM
const KEY_LENGTH = 32; // 256 bits
// Username validation
const USERNAME_REGEX = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
const USERNAME_MIN_LENGTH = 3;
const USERNAME_MAX_LENGTH = 32;
// Timestamp validation (5 minutes tolerance)
const TIMESTAMP_TOLERANCE_MS = 5 * 60 * 1000;
/**
* Generates a random peer ID (16 bytes = 32 hex chars)
*/
@@ -147,3 +164,199 @@ export async function validateCredentials(peerId: string, encryptedSecret: strin
return false;
}
}
// ===== Username and Ed25519 Signature Utilities =====
/**
* Validates username format
* Rules: 3-32 chars, lowercase alphanumeric + dash, must start/end with alphanumeric
*/
export function validateUsername(username: string): { valid: boolean; error?: string } {
if (typeof username !== 'string') {
return { valid: false, error: 'Username must be a string' };
}
if (username.length < USERNAME_MIN_LENGTH) {
return { valid: false, error: `Username must be at least ${USERNAME_MIN_LENGTH} characters` };
}
if (username.length > USERNAME_MAX_LENGTH) {
return { valid: false, error: `Username must be at most ${USERNAME_MAX_LENGTH} characters` };
}
if (!USERNAME_REGEX.test(username)) {
return { valid: false, error: 'Username must be lowercase alphanumeric with optional dashes, and start/end with alphanumeric' };
}
return { valid: true };
}
/**
* Validates service FQN format (service-name@version)
* Service name: reverse domain notation (com.example.service)
* Version: semantic versioning (1.0.0, 2.1.3-beta, etc.)
*/
export function validateServiceFqn(fqn: string): { valid: boolean; error?: string } {
if (typeof fqn !== 'string') {
return { valid: false, error: 'Service FQN must be a string' };
}
// Split into service name and version
const parts = fqn.split('@');
if (parts.length !== 2) {
return { valid: false, error: 'Service FQN must be in format: service-name@version' };
}
const [serviceName, version] = parts;
// Validate service name (reverse domain notation)
const serviceNameRegex = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
if (!serviceNameRegex.test(serviceName)) {
return { valid: false, error: 'Service name must be reverse domain notation (e.g., com.example.service)' };
}
if (serviceName.length < 3 || serviceName.length > 128) {
return { valid: false, error: 'Service name must be 3-128 characters' };
}
// Validate version (semantic versioning)
const versionRegex = /^[0-9]+\.[0-9]+\.[0-9]+(-[a-z0-9.-]+)?$/;
if (!versionRegex.test(version)) {
return { valid: false, error: 'Version must be semantic versioning (e.g., 1.0.0, 2.1.3-beta)' };
}
return { valid: true };
}
/**
* Validates timestamp is within acceptable range (prevents replay attacks)
*/
export function validateTimestamp(timestamp: number): { valid: boolean; error?: string } {
if (typeof timestamp !== 'number' || !Number.isFinite(timestamp)) {
return { valid: false, error: 'Timestamp must be a finite number' };
}
const now = Date.now();
const diff = Math.abs(now - timestamp);
if (diff > TIMESTAMP_TOLERANCE_MS) {
return { valid: false, error: `Timestamp too old or too far in future (tolerance: ${TIMESTAMP_TOLERANCE_MS / 1000}s)` };
}
return { valid: true };
}
/**
* Verifies Ed25519 signature
* @param publicKey Base64-encoded Ed25519 public key (32 bytes)
* @param signature Base64-encoded Ed25519 signature (64 bytes)
* @param message Message that was signed (UTF-8 string)
* @returns true if signature is valid, false otherwise
*/
export async function verifyEd25519Signature(
publicKey: string,
signature: string,
message: string
): Promise<boolean> {
try {
// Decode base64 to bytes
const publicKeyBytes = base64ToBytes(publicKey);
const signatureBytes = base64ToBytes(signature);
// Encode message as UTF-8
const encoder = new TextEncoder();
const messageBytes = encoder.encode(message);
// Verify signature using @noble/ed25519 (async version)
const isValid = await ed25519.verifyAsync(signatureBytes, messageBytes, publicKeyBytes);
return isValid;
} catch (err) {
console.error('Ed25519 signature verification failed:', err);
return false;
}
}
/**
* Validates a username claim request
* Verifies format, timestamp, and signature
*/
export async function validateUsernameClaim(
username: string,
publicKey: string,
signature: string,
message: string
): Promise<{ valid: boolean; error?: string }> {
// Validate username format
const usernameCheck = validateUsername(username);
if (!usernameCheck.valid) {
return usernameCheck;
}
// Parse message format: "claim:{username}:{timestamp}"
const parts = message.split(':');
if (parts.length !== 3 || parts[0] !== 'claim' || parts[1] !== username) {
return { valid: false, error: 'Invalid message format (expected: claim:{username}:{timestamp})' };
}
const timestamp = parseInt(parts[2], 10);
if (isNaN(timestamp)) {
return { valid: false, error: 'Invalid timestamp in message' };
}
// Validate timestamp
const timestampCheck = validateTimestamp(timestamp);
if (!timestampCheck.valid) {
return timestampCheck;
}
// Verify signature
const signatureValid = await verifyEd25519Signature(publicKey, signature, message);
if (!signatureValid) {
return { valid: false, error: 'Invalid signature' };
}
return { valid: true };
}
/**
* Validates a service publish signature
* Message format: publish:{username}:{serviceFqn}:{timestamp}
*/
export async function validateServicePublish(
username: string,
serviceFqn: string,
publicKey: string,
signature: string,
message: string
): Promise<{ valid: boolean; error?: string }> {
// Validate username format
const usernameCheck = validateUsername(username);
if (!usernameCheck.valid) {
return usernameCheck;
}
// Parse message format: "publish:{username}:{serviceFqn}:{timestamp}"
const parts = message.split(':');
if (parts.length !== 4 || parts[0] !== 'publish' || parts[1] !== username || parts[2] !== serviceFqn) {
return { valid: false, error: 'Invalid message format (expected: publish:{username}:{serviceFqn}:{timestamp})' };
}
const timestamp = parseInt(parts[3], 10);
if (isNaN(timestamp)) {
return { valid: false, error: 'Invalid timestamp in message' };
}
// Validate timestamp
const timestampCheck = validateTimestamp(timestamp);
if (!timestampCheck.valid) {
return timestampCheck;
}
// Verify signature
const signatureValid = await verifyEd25519Signature(publicKey, signature, message);
if (!signatureValid) {
return { valid: false, error: 'Invalid signature' };
}
return { valid: true };
}

View File

@@ -20,7 +20,6 @@ async function main() {
offerMinTtl: `${config.offerMinTtl}ms`,
cleanupInterval: `${config.cleanupInterval}ms`,
maxOffersPerRequest: config.maxOffersPerRequest,
maxTopicsPerOffer: config.maxTopicsPerOffer,
corsOrigins: config.corsOrigins,
version: config.version,
});

View File

@@ -1,9 +1,21 @@
import { Storage, Offer, IceCandidate, CreateOfferRequest, TopicInfo } from './types.ts';
// Use Web Crypto API (available globally in Cloudflare Workers)
import {
Storage,
Offer,
IceCandidate,
CreateOfferRequest,
Username,
ClaimUsernameRequest,
Service,
CreateServiceRequest,
ServiceInfo,
} from './types.ts';
import { generateOfferHash } from './hash-id.ts';
const YEAR_IN_MS = 365 * 24 * 60 * 60 * 1000; // 365 days
/**
* D1 storage adapter for topic-based offer management using Cloudflare D1
* NOTE: This implementation is a placeholder and needs to be fully tested
* D1 storage adapter for rondevu DNS-like system using Cloudflare D1
*/
export class D1Storage implements Storage {
private db: D1Database;
@@ -17,11 +29,12 @@ export class D1Storage implements Storage {
}
/**
* Initializes database schema with new topic-based structure
* Initializes database schema with username and service-based structure
* This should be run once during setup, not on every request
*/
async initializeDatabase(): Promise<void> {
await this.db.exec(`
-- WebRTC signaling offers
CREATE TABLE IF NOT EXISTS offers (
id TEXT PRIMARY KEY,
peer_id TEXT NOT NULL,
@@ -40,22 +53,13 @@ export class D1Storage implements Storage {
CREATE INDEX IF NOT EXISTS idx_offers_last_seen ON offers(last_seen);
CREATE INDEX IF NOT EXISTS idx_offers_answerer ON offers(answerer_peer_id);
CREATE TABLE IF NOT EXISTS offer_topics (
offer_id TEXT NOT NULL,
topic TEXT NOT NULL,
PRIMARY KEY (offer_id, topic),
FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_topics_topic ON offer_topics(topic);
CREATE INDEX IF NOT EXISTS idx_topics_offer ON offer_topics(offer_id);
-- ICE candidates table
CREATE TABLE IF NOT EXISTS ice_candidates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
offer_id TEXT NOT NULL,
peer_id TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('offerer', 'answerer')),
candidate TEXT NOT NULL, -- JSON: RTCIceCandidateInit object
candidate TEXT NOT NULL,
created_at INTEGER NOT NULL,
FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE
);
@@ -63,36 +67,76 @@ export class D1Storage implements Storage {
CREATE INDEX IF NOT EXISTS idx_ice_offer ON ice_candidates(offer_id);
CREATE INDEX IF NOT EXISTS idx_ice_peer ON ice_candidates(peer_id);
CREATE INDEX IF NOT EXISTS idx_ice_created ON ice_candidates(created_at);
-- Usernames table
CREATE TABLE IF NOT EXISTS usernames (
username TEXT PRIMARY KEY,
public_key TEXT NOT NULL UNIQUE,
claimed_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
last_used INTEGER NOT NULL,
metadata TEXT,
CHECK(length(username) >= 3 AND length(username) <= 32)
);
CREATE INDEX IF NOT EXISTS idx_usernames_expires ON usernames(expires_at);
CREATE INDEX IF NOT EXISTS idx_usernames_public_key ON usernames(public_key);
-- Services table
CREATE TABLE IF NOT EXISTS services (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
service_fqn TEXT NOT NULL,
offer_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
is_public INTEGER NOT NULL DEFAULT 0,
metadata TEXT,
FOREIGN KEY (username) REFERENCES usernames(username) ON DELETE CASCADE,
FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE,
UNIQUE(username, service_fqn)
);
CREATE INDEX IF NOT EXISTS idx_services_username ON services(username);
CREATE INDEX IF NOT EXISTS idx_services_fqn ON services(service_fqn);
CREATE INDEX IF NOT EXISTS idx_services_expires ON services(expires_at);
CREATE INDEX IF NOT EXISTS idx_services_offer ON services(offer_id);
-- Service index table (privacy layer)
CREATE TABLE IF NOT EXISTS service_index (
uuid TEXT PRIMARY KEY,
service_id TEXT NOT NULL,
username TEXT NOT NULL,
service_fqn TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_service_index_username ON service_index(username);
CREATE INDEX IF NOT EXISTS idx_service_index_expires ON service_index(expires_at);
`);
}
// ===== Offer Management =====
async createOffers(offers: CreateOfferRequest[]): Promise<Offer[]> {
const created: Offer[] = [];
// D1 doesn't support true transactions yet, so we do this sequentially
for (const offer of offers) {
const id = offer.id || await generateOfferHash(offer.sdp, offer.topics);
const id = offer.id || await generateOfferHash(offer.sdp);
const now = Date.now();
// Insert offer
await this.db.prepare(`
INSERT INTO offers (id, peer_id, sdp, created_at, expires_at, last_seen, secret)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).bind(id, offer.peerId, offer.sdp, now, offer.expiresAt, now, offer.secret || null).run();
// Insert topics
for (const topic of offer.topics) {
await this.db.prepare(`
INSERT INTO offer_topics (offer_id, topic)
VALUES (?, ?)
`).bind(id, topic).run();
}
created.push({
id,
peerId: offer.peerId,
sdp: offer.sdp,
topics: offer.topics,
createdAt: now,
expiresAt: offer.expiresAt,
lastSeen: now,
@@ -103,33 +147,6 @@ export class D1Storage implements Storage {
return created;
}
async getOffersByTopic(topic: string, excludePeerIds?: string[]): Promise<Offer[]> {
let query = `
SELECT DISTINCT o.*
FROM offers o
INNER JOIN offer_topics ot ON o.id = ot.offer_id
WHERE ot.topic = ? AND o.expires_at > ?
`;
const params: any[] = [topic, Date.now()];
if (excludePeerIds && excludePeerIds.length > 0) {
const placeholders = excludePeerIds.map(() => '?').join(',');
query += ` AND o.peer_id NOT IN (${placeholders})`;
params.push(...excludePeerIds);
}
query += ' ORDER BY o.last_seen DESC';
const result = await this.db.prepare(query).bind(...params).all();
if (!result.results) {
return [];
}
return Promise.all(result.results.map(row => this.rowToOffer(row as any)));
}
async getOffersByPeerId(peerId: string): Promise<Offer[]> {
const result = await this.db.prepare(`
SELECT * FROM offers
@@ -141,7 +158,7 @@ export class D1Storage implements Storage {
return [];
}
return Promise.all(result.results.map(row => this.rowToOffer(row as any)));
return result.results.map(row => this.rowToOffer(row as any));
}
async getOfferById(offerId: string): Promise<Offer | null> {
@@ -234,21 +251,20 @@ export class D1Storage implements Storage {
return [];
}
return Promise.all(result.results.map(row => this.rowToOffer(row as any)));
return result.results.map(row => this.rowToOffer(row as any));
}
// ===== ICE Candidate Management =====
async addIceCandidates(
offerId: string,
peerId: string,
role: 'offerer' | 'answerer',
candidates: any[]
): Promise<number> {
console.log(`[D1] addIceCandidates: offerId=${offerId}, peerId=${peerId}, role=${role}, count=${candidates.length}`);
// Give each candidate a unique timestamp to avoid "since" filtering issues
// D1 doesn't have transactions, so insert one by one
for (let i = 0; i < candidates.length; i++) {
const timestamp = Date.now() + i; // Ensure unique timestamps
const timestamp = Date.now() + i;
await this.db.prepare(`
INSERT INTO ice_candidates (offer_id, peer_id, role, candidate, created_at)
VALUES (?, ?, ?, ?, ?)
@@ -256,7 +272,7 @@ export class D1Storage implements Storage {
offerId,
peerId,
role,
JSON.stringify(candidates[i]), // Store full object as JSON
JSON.stringify(candidates[i]),
timestamp
).run();
}
@@ -283,82 +299,232 @@ export class D1Storage implements Storage {
query += ' ORDER BY created_at ASC';
console.log(`[D1] getIceCandidates query: offerId=${offerId}, targetRole=${targetRole}, since=${since}`);
const result = await this.db.prepare(query).bind(...params).all();
console.log(`[D1] getIceCandidates result: ${result.results?.length || 0} rows`);
if (!result.results) {
return [];
}
const candidates = result.results.map((row: any) => ({
return result.results.map((row: any) => ({
id: row.id,
offerId: row.offer_id,
peerId: row.peer_id,
role: row.role,
candidate: JSON.parse(row.candidate), // Parse JSON back to object
candidate: JSON.parse(row.candidate),
createdAt: row.created_at,
}));
if (candidates.length > 0) {
console.log(`[D1] First candidate createdAt: ${candidates[0].createdAt}, since: ${since}`);
}
return candidates;
// ===== Username Management =====
async claimUsername(request: ClaimUsernameRequest): Promise<Username> {
const now = Date.now();
const expiresAt = now + YEAR_IN_MS;
// Try to insert or update
const result = await this.db.prepare(`
INSERT INTO usernames (username, public_key, claimed_at, expires_at, last_used, metadata)
VALUES (?, ?, ?, ?, ?, NULL)
ON CONFLICT(username) DO UPDATE SET
expires_at = ?,
last_used = ?
WHERE public_key = ?
`).bind(
request.username,
request.publicKey,
now,
expiresAt,
now,
expiresAt,
now,
request.publicKey
).run();
if ((result.meta.changes || 0) === 0) {
throw new Error('Username already claimed by different public key');
}
async getTopics(limit: number, offset: number, startsWith?: string): Promise<{
topics: TopicInfo[];
total: number;
return {
username: request.username,
publicKey: request.publicKey,
claimedAt: now,
expiresAt,
lastUsed: now,
};
}
async getUsername(username: string): Promise<Username | null> {
const result = await this.db.prepare(`
SELECT * FROM usernames
WHERE username = ? AND expires_at > ?
`).bind(username, Date.now()).first();
if (!result) {
return null;
}
const row = result as any;
return {
username: row.username,
publicKey: row.public_key,
claimedAt: row.claimed_at,
expiresAt: row.expires_at,
lastUsed: row.last_used,
metadata: row.metadata || undefined,
};
}
async touchUsername(username: string): Promise<boolean> {
const now = Date.now();
const expiresAt = now + YEAR_IN_MS;
const result = await this.db.prepare(`
UPDATE usernames
SET last_used = ?, expires_at = ?
WHERE username = ? AND expires_at > ?
`).bind(now, expiresAt, username, now).run();
return (result.meta.changes || 0) > 0;
}
async deleteExpiredUsernames(now: number): Promise<number> {
const result = await this.db.prepare(`
DELETE FROM usernames WHERE expires_at < ?
`).bind(now).run();
return result.meta.changes || 0;
}
// ===== Service Management =====
async createService(request: CreateServiceRequest): Promise<{
service: Service;
indexUuid: string;
}> {
const serviceId = crypto.randomUUID();
const indexUuid = crypto.randomUUID();
const now = Date.now();
// Build WHERE clause for startsWith filter
const whereClause = startsWith
? 'o.expires_at > ? AND ot.topic LIKE ?'
: 'o.expires_at > ?';
// Insert service
await this.db.prepare(`
INSERT INTO services (id, username, service_fqn, offer_id, created_at, expires_at, is_public, metadata)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).bind(
serviceId,
request.username,
request.serviceFqn,
request.offerId,
now,
request.expiresAt,
request.isPublic ? 1 : 0,
request.metadata || null
).run();
const startsWithPattern = startsWith ? `${startsWith}%` : null;
// Insert service index
await this.db.prepare(`
INSERT INTO service_index (uuid, service_id, username, service_fqn, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?)
`).bind(
indexUuid,
serviceId,
request.username,
request.serviceFqn,
now,
request.expiresAt
).run();
// Get total count of topics with active offers
const countQuery = `
SELECT COUNT(DISTINCT ot.topic) as count
FROM offer_topics ot
INNER JOIN offers o ON ot.offer_id = o.id
WHERE ${whereClause}
`;
// Touch username to extend expiry
await this.touchUsername(request.username);
const countStmt = this.db.prepare(countQuery);
const countResult = startsWith
? await countStmt.bind(now, startsWithPattern).first()
: await countStmt.bind(now).first();
return {
service: {
id: serviceId,
username: request.username,
serviceFqn: request.serviceFqn,
offerId: request.offerId,
createdAt: now,
expiresAt: request.expiresAt,
isPublic: request.isPublic || false,
metadata: request.metadata,
},
indexUuid,
};
}
const total = (countResult as any)?.count || 0;
async getServiceById(serviceId: string): Promise<Service | null> {
const result = await this.db.prepare(`
SELECT * FROM services
WHERE id = ? AND expires_at > ?
`).bind(serviceId, Date.now()).first();
// Get topics with peer counts (paginated)
const topicsQuery = `
SELECT
ot.topic,
COUNT(DISTINCT o.peer_id) as active_peers
FROM offer_topics ot
INNER JOIN offers o ON ot.offer_id = o.id
WHERE ${whereClause}
GROUP BY ot.topic
ORDER BY active_peers DESC, ot.topic ASC
LIMIT ? OFFSET ?
`;
if (!result) {
return null;
}
const topicsStmt = this.db.prepare(topicsQuery);
const topicsResult = startsWith
? await topicsStmt.bind(now, startsWithPattern, limit, offset).all()
: await topicsStmt.bind(now, limit, offset).all();
return this.rowToService(result as any);
}
const topics = (topicsResult.results || []).map((row: any) => ({
topic: row.topic,
activePeers: row.active_peers,
async getServiceByUuid(uuid: string): Promise<Service | null> {
const result = await this.db.prepare(`
SELECT s.* FROM services s
INNER JOIN service_index si ON s.id = si.service_id
WHERE si.uuid = ? AND s.expires_at > ?
`).bind(uuid, Date.now()).first();
if (!result) {
return null;
}
return this.rowToService(result as any);
}
async listServicesForUsername(username: string): Promise<ServiceInfo[]> {
const result = await this.db.prepare(`
SELECT si.uuid, s.is_public, s.service_fqn, s.metadata
FROM service_index si
INNER JOIN services s ON si.service_id = s.id
WHERE si.username = ? AND si.expires_at > ?
ORDER BY s.created_at DESC
`).bind(username, Date.now()).all();
if (!result.results) {
return [];
}
return result.results.map((row: any) => ({
uuid: row.uuid,
isPublic: row.is_public === 1,
serviceFqn: row.is_public === 1 ? row.service_fqn : undefined,
metadata: row.is_public === 1 ? row.metadata || undefined : undefined,
}));
}
return { topics, total };
async queryService(username: string, serviceFqn: string): Promise<string | null> {
const result = await this.db.prepare(`
SELECT si.uuid FROM service_index si
INNER JOIN services s ON si.service_id = s.id
WHERE si.username = ? AND si.service_fqn = ? AND si.expires_at > ?
`).bind(username, serviceFqn, Date.now()).first();
return result ? (result as any).uuid : null;
}
async deleteService(serviceId: string, username: string): Promise<boolean> {
const result = await this.db.prepare(`
DELETE FROM services
WHERE id = ? AND username = ?
`).bind(serviceId, username).run();
return (result.meta.changes || 0) > 0;
}
async deleteExpiredServices(now: number): Promise<number> {
const result = await this.db.prepare(`
DELETE FROM services WHERE expires_at < ?
`).bind(now).run();
return result.meta.changes || 0;
}
async close(): Promise<void> {
@@ -366,22 +532,16 @@ export class D1Storage implements Storage {
// Connections are managed by the Cloudflare Workers runtime
}
// ===== Helper Methods =====
/**
* Helper method to convert database row to Offer object with topics
* Helper method to convert database row to Offer object
*/
private async rowToOffer(row: any): Promise<Offer> {
// Get topics for this offer
const topicResult = await this.db.prepare(`
SELECT topic FROM offer_topics WHERE offer_id = ?
`).bind(row.id).all();
const topics = topicResult.results?.map((t: any) => t.topic) || [];
private rowToOffer(row: any): Offer {
return {
id: row.id,
peerId: row.peer_id,
sdp: row.sdp,
topics,
createdAt: row.created_at,
expiresAt: row.expires_at,
lastSeen: row.last_seen,
@@ -391,4 +551,20 @@ export class D1Storage implements Storage {
answeredAt: row.answered_at || undefined,
};
}
/**
* Helper method to convert database row to Service object
*/
private rowToService(row: any): Service {
return {
id: row.id,
username: row.username,
serviceFqn: row.service_fqn,
offerId: row.offer_id,
createdAt: row.created_at,
expiresAt: row.expires_at,
isPublic: row.is_public === 1,
metadata: row.metadata || undefined,
};
}
}

View File

@@ -1,22 +1,17 @@
/**
* Generates a content-based offer ID using SHA-256 hash
* Creates deterministic IDs based on offer content (sdp, topics)
* Creates deterministic IDs based on offer SDP content
* PeerID is not included as it's inferred from authentication
* Uses Web Crypto API for compatibility with both Node.js and Cloudflare Workers
*
* @param sdp - The WebRTC SDP offer
* @param topics - Array of topic strings
* @returns SHA-256 hash of the sanitized offer content
* @returns SHA-256 hash of the SDP content
*/
export async function generateOfferHash(
sdp: string,
topics: string[]
): Promise<string> {
export async function generateOfferHash(sdp: string): Promise<string> {
// Sanitize and normalize the offer content
// Only include core offer content (not peerId - that's inferred from auth)
const sanitizedOffer = {
sdp,
topics: [...topics].sort(), // Sort topics for consistency
sdp
};
// Create non-prettified JSON string

View File

@@ -1,9 +1,22 @@
import Database from 'better-sqlite3';
import { Storage, Offer, IceCandidate, CreateOfferRequest, TopicInfo } from './types.ts';
import { randomUUID } from 'node:crypto';
import {
Storage,
Offer,
IceCandidate,
CreateOfferRequest,
Username,
ClaimUsernameRequest,
Service,
CreateServiceRequest,
ServiceInfo,
} from './types.ts';
import { generateOfferHash } from './hash-id.ts';
const YEAR_IN_MS = 365 * 24 * 60 * 60 * 1000; // 365 days
/**
* SQLite storage adapter for topic-based offer management
* SQLite storage adapter for rondevu DNS-like system
* Supports both file-based and in-memory databases
*/
export class SQLiteStorage implements Storage {
@@ -19,10 +32,11 @@ export class SQLiteStorage implements Storage {
}
/**
* Initializes database schema with new topic-based structure
* Initializes database schema with username and service-based structure
*/
private initializeDatabase(): void {
this.db.exec(`
-- WebRTC signaling offers
CREATE TABLE IF NOT EXISTS offers (
id TEXT PRIMARY KEY,
peer_id TEXT NOT NULL,
@@ -41,22 +55,13 @@ export class SQLiteStorage implements Storage {
CREATE INDEX IF NOT EXISTS idx_offers_last_seen ON offers(last_seen);
CREATE INDEX IF NOT EXISTS idx_offers_answerer ON offers(answerer_peer_id);
CREATE TABLE IF NOT EXISTS offer_topics (
offer_id TEXT NOT NULL,
topic TEXT NOT NULL,
PRIMARY KEY (offer_id, topic),
FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_topics_topic ON offer_topics(topic);
CREATE INDEX IF NOT EXISTS idx_topics_offer ON offer_topics(offer_id);
-- ICE candidates table
CREATE TABLE IF NOT EXISTS ice_candidates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
offer_id TEXT NOT NULL,
peer_id TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('offerer', 'answerer')),
candidate TEXT NOT NULL, -- JSON: RTCIceCandidateInit object
candidate TEXT NOT NULL,
created_at INTEGER NOT NULL,
FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE
);
@@ -64,12 +69,62 @@ export class SQLiteStorage implements Storage {
CREATE INDEX IF NOT EXISTS idx_ice_offer ON ice_candidates(offer_id);
CREATE INDEX IF NOT EXISTS idx_ice_peer ON ice_candidates(peer_id);
CREATE INDEX IF NOT EXISTS idx_ice_created ON ice_candidates(created_at);
-- Usernames table
CREATE TABLE IF NOT EXISTS usernames (
username TEXT PRIMARY KEY,
public_key TEXT NOT NULL UNIQUE,
claimed_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
last_used INTEGER NOT NULL,
metadata TEXT,
CHECK(length(username) >= 3 AND length(username) <= 32)
);
CREATE INDEX IF NOT EXISTS idx_usernames_expires ON usernames(expires_at);
CREATE INDEX IF NOT EXISTS idx_usernames_public_key ON usernames(public_key);
-- Services table
CREATE TABLE IF NOT EXISTS services (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
service_fqn TEXT NOT NULL,
offer_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
is_public INTEGER NOT NULL DEFAULT 0,
metadata TEXT,
FOREIGN KEY (username) REFERENCES usernames(username) ON DELETE CASCADE,
FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE,
UNIQUE(username, service_fqn)
);
CREATE INDEX IF NOT EXISTS idx_services_username ON services(username);
CREATE INDEX IF NOT EXISTS idx_services_fqn ON services(service_fqn);
CREATE INDEX IF NOT EXISTS idx_services_expires ON services(expires_at);
CREATE INDEX IF NOT EXISTS idx_services_offer ON services(offer_id);
-- Service index table (privacy layer)
CREATE TABLE IF NOT EXISTS service_index (
uuid TEXT PRIMARY KEY,
service_id TEXT NOT NULL,
username TEXT NOT NULL,
service_fqn TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_service_index_username ON service_index(username);
CREATE INDEX IF NOT EXISTS idx_service_index_expires ON service_index(expires_at);
`);
// Enable foreign keys
this.db.pragma('foreign_keys = ON');
}
// ===== Offer Management =====
async createOffers(offers: CreateOfferRequest[]): Promise<Offer[]> {
const created: Offer[] = [];
@@ -77,7 +132,7 @@ export class SQLiteStorage implements Storage {
const offersWithIds = await Promise.all(
offers.map(async (offer) => ({
...offer,
id: offer.id || await generateOfferHash(offer.sdp, offer.topics),
id: offer.id || await generateOfferHash(offer.sdp),
}))
);
@@ -88,11 +143,6 @@ export class SQLiteStorage implements Storage {
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
const topicStmt = this.db.prepare(`
INSERT INTO offer_topics (offer_id, topic)
VALUES (?, ?)
`);
for (const offer of offersWithIds) {
const now = Date.now();
@@ -107,16 +157,10 @@ export class SQLiteStorage implements Storage {
offer.secret || null
);
// Insert topics
for (const topic of offer.topics) {
topicStmt.run(offer.id, topic);
}
created.push({
id: offer.id,
peerId: offer.peerId,
sdp: offer.sdp,
topics: offer.topics,
createdAt: now,
expiresAt: offer.expiresAt,
lastSeen: now,
@@ -129,30 +173,6 @@ export class SQLiteStorage implements Storage {
return created;
}
async getOffersByTopic(topic: string, excludePeerIds?: string[]): Promise<Offer[]> {
let query = `
SELECT DISTINCT o.*
FROM offers o
INNER JOIN offer_topics ot ON o.id = ot.offer_id
WHERE ot.topic = ? AND o.expires_at > ?
`;
const params: any[] = [topic, Date.now()];
if (excludePeerIds && excludePeerIds.length > 0) {
const placeholders = excludePeerIds.map(() => '?').join(',');
query += ` AND o.peer_id NOT IN (${placeholders})`;
params.push(...excludePeerIds);
}
query += ' ORDER BY o.last_seen DESC';
const stmt = this.db.prepare(query);
const rows = stmt.all(...params) as any[];
return Promise.all(rows.map(row => this.rowToOffer(row)));
}
async getOffersByPeerId(peerId: string): Promise<Offer[]> {
const stmt = this.db.prepare(`
SELECT * FROM offers
@@ -161,7 +181,7 @@ export class SQLiteStorage implements Storage {
`);
const rows = stmt.all(peerId, Date.now()) as any[];
return Promise.all(rows.map(row => this.rowToOffer(row)));
return rows.map(row => this.rowToOffer(row));
}
async getOfferById(offerId: string): Promise<Offer | null> {
@@ -254,9 +274,11 @@ export class SQLiteStorage implements Storage {
`);
const rows = stmt.all(offererPeerId, Date.now()) as any[];
return Promise.all(rows.map(row => this.rowToOffer(row)));
return rows.map(row => this.rowToOffer(row));
}
// ===== ICE Candidate Management =====
async addIceCandidates(
offerId: string,
peerId: string,
@@ -275,8 +297,8 @@ export class SQLiteStorage implements Storage {
offerId,
peerId,
role,
JSON.stringify(candidates[i]), // Store full object as JSON
baseTimestamp + i // Ensure unique timestamps to avoid "since" filtering issues
JSON.stringify(candidates[i]),
baseTimestamp + i
);
}
});
@@ -312,85 +334,249 @@ export class SQLiteStorage implements Storage {
offerId: row.offer_id,
peerId: row.peer_id,
role: row.role,
candidate: JSON.parse(row.candidate), // Parse JSON back to object
candidate: JSON.parse(row.candidate),
createdAt: row.created_at,
}));
}
async getTopics(limit: number, offset: number, startsWith?: string): Promise<{
topics: TopicInfo[];
total: number;
// ===== Username Management =====
async claimUsername(request: ClaimUsernameRequest): Promise<Username> {
const now = Date.now();
const expiresAt = now + YEAR_IN_MS;
// Try to insert or update
const stmt = this.db.prepare(`
INSERT INTO usernames (username, public_key, claimed_at, expires_at, last_used, metadata)
VALUES (?, ?, ?, ?, ?, NULL)
ON CONFLICT(username) DO UPDATE SET
expires_at = ?,
last_used = ?
WHERE public_key = ?
`);
const result = stmt.run(
request.username,
request.publicKey,
now,
expiresAt,
now,
expiresAt,
now,
request.publicKey
);
if (result.changes === 0) {
throw new Error('Username already claimed by different public key');
}
return {
username: request.username,
publicKey: request.publicKey,
claimedAt: now,
expiresAt,
lastUsed: now,
};
}
async getUsername(username: string): Promise<Username | null> {
const stmt = this.db.prepare(`
SELECT * FROM usernames
WHERE username = ? AND expires_at > ?
`);
const row = stmt.get(username, Date.now()) as any;
if (!row) {
return null;
}
return {
username: row.username,
publicKey: row.public_key,
claimedAt: row.claimed_at,
expiresAt: row.expires_at,
lastUsed: row.last_used,
metadata: row.metadata || undefined,
};
}
async touchUsername(username: string): Promise<boolean> {
const now = Date.now();
const expiresAt = now + YEAR_IN_MS;
const stmt = this.db.prepare(`
UPDATE usernames
SET last_used = ?, expires_at = ?
WHERE username = ? AND expires_at > ?
`);
const result = stmt.run(now, expiresAt, username, now);
return result.changes > 0;
}
async deleteExpiredUsernames(now: number): Promise<number> {
const stmt = this.db.prepare('DELETE FROM usernames WHERE expires_at < ?');
const result = stmt.run(now);
return result.changes;
}
// ===== Service Management =====
async createService(request: CreateServiceRequest): Promise<{
service: Service;
indexUuid: string;
}> {
const serviceId = randomUUID();
const indexUuid = randomUUID();
const now = Date.now();
// Build WHERE clause for startsWith filter
const whereClause = startsWith
? 'o.expires_at > ? AND ot.topic LIKE ?'
: 'o.expires_at > ?';
const transaction = this.db.transaction(() => {
// Insert service
const serviceStmt = this.db.prepare(`
INSERT INTO services (id, username, service_fqn, offer_id, created_at, expires_at, is_public, metadata)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
const startsWithPattern = startsWith ? `${startsWith}%` : null;
serviceStmt.run(
serviceId,
request.username,
request.serviceFqn,
request.offerId,
now,
request.expiresAt,
request.isPublic ? 1 : 0,
request.metadata || null
);
// Get total count of topics with active offers
const countQuery = `
SELECT COUNT(DISTINCT ot.topic) as count
FROM offer_topics ot
INNER JOIN offers o ON ot.offer_id = o.id
WHERE ${whereClause}
`;
// Insert service index
const indexStmt = this.db.prepare(`
INSERT INTO service_index (uuid, service_id, username, service_fqn, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?)
`);
const countStmt = this.db.prepare(countQuery);
const countParams = startsWith ? [now, startsWithPattern] : [now];
const countRow = countStmt.get(...countParams) as any;
const total = countRow.count;
indexStmt.run(
indexUuid,
serviceId,
request.username,
request.serviceFqn,
now,
request.expiresAt
);
// Get topics with peer counts (paginated)
const topicsQuery = `
SELECT
ot.topic,
COUNT(DISTINCT o.peer_id) as active_peers
FROM offer_topics ot
INNER JOIN offers o ON ot.offer_id = o.id
WHERE ${whereClause}
GROUP BY ot.topic
ORDER BY active_peers DESC, ot.topic ASC
LIMIT ? OFFSET ?
`;
// Touch username to extend expiry
this.touchUsername(request.username);
});
const topicsStmt = this.db.prepare(topicsQuery);
const topicsParams = startsWith
? [now, startsWithPattern, limit, offset]
: [now, limit, offset];
const rows = topicsStmt.all(...topicsParams) as any[];
transaction();
const topics = rows.map(row => ({
topic: row.topic,
activePeers: row.active_peers,
return {
service: {
id: serviceId,
username: request.username,
serviceFqn: request.serviceFqn,
offerId: request.offerId,
createdAt: now,
expiresAt: request.expiresAt,
isPublic: request.isPublic || false,
metadata: request.metadata,
},
indexUuid,
};
}
async getServiceById(serviceId: string): Promise<Service | null> {
const stmt = this.db.prepare(`
SELECT * FROM services
WHERE id = ? AND expires_at > ?
`);
const row = stmt.get(serviceId, Date.now()) as any;
if (!row) {
return null;
}
return this.rowToService(row);
}
async getServiceByUuid(uuid: string): Promise<Service | null> {
const stmt = this.db.prepare(`
SELECT s.* FROM services s
INNER JOIN service_index si ON s.id = si.service_id
WHERE si.uuid = ? AND s.expires_at > ?
`);
const row = stmt.get(uuid, Date.now()) as any;
if (!row) {
return null;
}
return this.rowToService(row);
}
async listServicesForUsername(username: string): Promise<ServiceInfo[]> {
const stmt = this.db.prepare(`
SELECT si.uuid, s.is_public, s.service_fqn, s.metadata
FROM service_index si
INNER JOIN services s ON si.service_id = s.id
WHERE si.username = ? AND si.expires_at > ?
ORDER BY s.created_at DESC
`);
const rows = stmt.all(username, Date.now()) as any[];
return rows.map(row => ({
uuid: row.uuid,
isPublic: row.is_public === 1,
serviceFqn: row.is_public === 1 ? row.service_fqn : undefined,
metadata: row.is_public === 1 ? row.metadata || undefined : undefined,
}));
}
return { topics, total };
async queryService(username: string, serviceFqn: string): Promise<string | null> {
const stmt = this.db.prepare(`
SELECT si.uuid FROM service_index si
INNER JOIN services s ON si.service_id = s.id
WHERE si.username = ? AND si.service_fqn = ? AND si.expires_at > ?
`);
const row = stmt.get(username, serviceFqn, Date.now()) as any;
return row ? row.uuid : null;
}
async deleteService(serviceId: string, username: string): Promise<boolean> {
const stmt = this.db.prepare(`
DELETE FROM services
WHERE id = ? AND username = ?
`);
const result = stmt.run(serviceId, username);
return result.changes > 0;
}
async deleteExpiredServices(now: number): Promise<number> {
const stmt = this.db.prepare('DELETE FROM services WHERE expires_at < ?');
const result = stmt.run(now);
return result.changes;
}
async close(): Promise<void> {
this.db.close();
}
// ===== Helper Methods =====
/**
* Helper method to convert database row to Offer object with topics
* Helper method to convert database row to Offer object
*/
private async rowToOffer(row: any): Promise<Offer> {
// Get topics for this offer
const topicStmt = this.db.prepare(`
SELECT topic FROM offer_topics WHERE offer_id = ?
`);
const topicRows = topicStmt.all(row.id) as any[];
const topics = topicRows.map(t => t.topic);
private rowToOffer(row: any): Offer {
return {
id: row.id,
peerId: row.peer_id,
sdp: row.sdp,
topics,
createdAt: row.created_at,
expiresAt: row.expires_at,
lastSeen: row.last_seen,
@@ -400,4 +586,20 @@ export class SQLiteStorage implements Storage {
answeredAt: row.answered_at || undefined,
};
}
/**
* Helper method to convert database row to Service object
*/
private rowToService(row: any): Service {
return {
id: row.id,
username: row.username,
serviceFqn: row.service_fqn,
offerId: row.offer_id,
createdAt: row.created_at,
expiresAt: row.expires_at,
isPublic: row.is_public === 1,
metadata: row.metadata || undefined,
};
}
}

View File

@@ -1,11 +1,10 @@
/**
* Represents a WebRTC signaling offer with topic-based discovery
* Represents a WebRTC signaling offer
*/
export interface Offer {
id: string;
peerId: string;
sdp: string;
topics: string[];
createdAt: number;
expiresAt: number;
lastSeen: number;
@@ -28,14 +27,6 @@ export interface IceCandidate {
createdAt: number;
}
/**
* Represents a topic with active peer count
*/
export interface TopicInfo {
topic: string;
activePeers: number;
}
/**
* Request to create a new offer
*/
@@ -43,16 +34,87 @@ export interface CreateOfferRequest {
id?: string;
peerId: string;
sdp: string;
topics: string[];
expiresAt: number;
secret?: string;
}
/**
* Storage interface for offer management with topic-based discovery
* Implementations can use different backends (SQLite, D1, Memory, etc.)
* Represents a claimed username with cryptographic proof
*/
export interface Username {
username: string;
publicKey: string; // Base64-encoded Ed25519 public key
claimedAt: number;
expiresAt: number; // 365 days from claim/last use
lastUsed: number;
metadata?: string; // JSON optional user metadata
}
/**
* Request to claim a username
*/
export interface ClaimUsernameRequest {
username: string;
publicKey: string;
signature: string;
message: string; // "claim:{username}:{timestamp}"
}
/**
* Represents a published service
*/
export interface Service {
id: string; // UUID v4
username: string;
serviceFqn: string; // com.example.chat@1.0.0
offerId: string; // Links to offers table
createdAt: number;
expiresAt: number;
isPublic: boolean;
metadata?: string; // JSON service description
}
/**
* Request to create a service
*/
export interface CreateServiceRequest {
username: string;
serviceFqn: string;
offerId: string;
expiresAt: number;
isPublic?: boolean;
metadata?: string;
}
/**
* Represents a service index entry (privacy layer)
*/
export interface ServiceIndex {
uuid: string; // Random UUID for privacy
serviceId: string;
username: string;
serviceFqn: string;
createdAt: number;
expiresAt: number;
}
/**
* Service info for discovery (privacy-aware)
*/
export interface ServiceInfo {
uuid: string;
isPublic: boolean;
serviceFqn?: string; // Only present if public
metadata?: string; // Only present if public
}
/**
* Storage interface for rondevu DNS-like system
* Implementations can use different backends (SQLite, D1, etc.)
*/
export interface Storage {
// ===== Offer Management =====
/**
* Creates one or more offers
* @param offers Array of offer creation requests
@@ -60,14 +122,6 @@ export interface Storage {
*/
createOffers(offers: CreateOfferRequest[]): Promise<Offer[]>;
/**
* Retrieves offers by topic with optional peer ID exclusion
* @param topic Topic to search for
* @param excludePeerIds Optional array of peer IDs to exclude
* @returns Array of offers matching the topic
*/
getOffersByTopic(topic: string, excludePeerIds?: string[]): Promise<Offer[]>;
/**
* Retrieves all offers from a specific peer
* @param peerId Peer identifier
@@ -117,6 +171,8 @@ export interface Storage {
*/
getAnsweredOffers(offererPeerId: string): Promise<Offer[]>;
// ===== ICE Candidate Management =====
/**
* Adds ICE candidates for an offer
* @param offerId Offer identifier
@@ -145,18 +201,92 @@ export interface Storage {
since?: number
): Promise<IceCandidate[]>;
// ===== Username Management =====
/**
* Retrieves topics with active peer counts (paginated)
* @param limit Maximum number of topics to return
* @param offset Number of topics to skip
* @param startsWith Optional prefix filter - only return topics starting with this string
* @returns Object with topics array and total count
* Claims a username (or refreshes expiry if already owned)
* @param request Username claim request with signature
* @returns Created/updated username record
*/
getTopics(limit: number, offset: number, startsWith?: string): Promise<{
topics: TopicInfo[];
total: number;
claimUsername(request: ClaimUsernameRequest): Promise<Username>;
/**
* Gets a username record
* @param username Username to look up
* @returns Username record if claimed, null otherwise
*/
getUsername(username: string): Promise<Username | null>;
/**
* Updates the last_used timestamp for a username (extends expiry)
* @param username Username to update
* @returns true if updated, false if not found
*/
touchUsername(username: string): Promise<boolean>;
/**
* Deletes all expired usernames
* @param now Current timestamp
* @returns Number of usernames deleted
*/
deleteExpiredUsernames(now: number): Promise<number>;
// ===== Service Management =====
/**
* Creates a new service
* @param request Service creation request
* @returns Created service with generated ID and index UUID
*/
createService(request: CreateServiceRequest): Promise<{
service: Service;
indexUuid: string;
}>;
/**
* Gets a service by its service ID
* @param serviceId Service ID
* @returns Service if found, null otherwise
*/
getServiceById(serviceId: string): Promise<Service | null>;
/**
* Gets a service by its index UUID
* @param uuid Index UUID
* @returns Service if found, null otherwise
*/
getServiceByUuid(uuid: string): Promise<Service | null>;
/**
* Lists all services for a username (with privacy filtering)
* @param username Username to query
* @returns Array of service info (UUIDs only for private services)
*/
listServicesForUsername(username: string): Promise<ServiceInfo[]>;
/**
* Queries a service by username and FQN
* @param username Username
* @param serviceFqn Service FQN
* @returns Service index UUID if found, null otherwise
*/
queryService(username: string, serviceFqn: string): Promise<string | null>;
/**
* Deletes a service (with ownership verification)
* @param serviceId Service ID
* @param username Owner username (for verification)
* @returns true if deleted, false if not found or not owned
*/
deleteService(serviceId: string, username: string): Promise<boolean>;
/**
* Deletes all expired services
* @param now Current timestamp
* @returns Number of services deleted
*/
deleteExpiredServices(now: number): Promise<number>;
/**
* Closes the storage connection and releases resources
*/

View File

@@ -13,7 +13,6 @@ export interface Env {
OFFER_MAX_TTL?: string;
OFFER_MIN_TTL?: string;
MAX_OFFERS_PER_REQUEST?: string;
MAX_TOPICS_PER_OFFER?: string;
CORS_ORIGINS?: string;
VERSION?: string;
}
@@ -43,8 +42,7 @@ export default {
offerMaxTtl: env.OFFER_MAX_TTL ? parseInt(env.OFFER_MAX_TTL, 10) : 86400000,
offerMinTtl: env.OFFER_MIN_TTL ? parseInt(env.OFFER_MIN_TTL, 10) : 60000,
cleanupInterval: 60000, // Not used in Workers (scheduled handler instead)
maxOffersPerRequest: env.MAX_OFFERS_PER_REQUEST ? parseInt(env.MAX_OFFERS_PER_REQUEST, 10) : 100,
maxTopicsPerOffer: env.MAX_TOPICS_PER_OFFER ? parseInt(env.MAX_TOPICS_PER_OFFER, 10) : 50,
maxOffersPerRequest: env.MAX_OFFERS_PER_REQUEST ? parseInt(env.MAX_OFFERS_PER_REQUEST, 10) : 100
};
// Create Hono app