17 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
14 changed files with 1713 additions and 747 deletions

292
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) [![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:** **Related repositories:**
- [@xtr-dev/rondevu-client](https://github.com/xtr-dev/rondevu-client) - TypeScript client library ([npm](https://www.npmjs.com/package/@xtr-dev/rondevu-client)) - [@xtr-dev/rondevu-client](https://github.com/xtr-dev/rondevu-client) - TypeScript client library ([npm](https://www.npmjs.com/package/@xtr-dev/rondevu-client))
@@ -15,14 +15,28 @@ Scalable peer-to-peer connection establishment with topic-based discovery, state
## Features ## 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 - **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 - **Complete WebRTC Signaling**: Offer/answer exchange and ICE candidate relay
- **Dual Storage**: SQLite (Node.js/Docker) and Cloudflare D1 (Workers) backends - **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 ## Quick Start
**Node.js:** **Node.js:**
@@ -32,7 +46,7 @@ npm install && npm start
**Docker:** **Docker:**
```bash ```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:** **Cloudflare Workers:**
@@ -53,16 +67,7 @@ Health check endpoint with version
#### `POST /register` #### `POST /register`
Register a new peer and receive credentials (peerId + secret) Register a new peer and receive credentials (peerId + secret)
**Request (optional):** Generates a cryptographically random 128-bit peer ID.
```json
{
"peerId": "my-custom-peer-id"
}
```
**Notes:**
- `peerId` (optional): Custom peer ID (1-128 characters). If not provided, a random ID will be generated.
- Returns 409 Conflict if the custom peer ID is already in use.
**Response:** **Response:**
```json ```json
@@ -72,67 +77,172 @@ Register a new peer and receive credentials (peerId + secret)
} }
``` ```
#### `GET /topics?limit=50&offset=0` ### Username Management
List all topics with active peer counts (paginated)
**Query Parameters:** #### `POST /usernames/claim`
- `limit` (optional): Maximum number of topics to return (default: 50, max: 200) Claim a username with cryptographic proof
- `offset` (optional): Number of topics to skip (default: 0)
**Response:** **Request:**
```json ```json
{ {
"topics": [ "username": "alice",
{"topic": "movie-xyz", "activePeers": 42}, "publicKey": "base64-encoded-ed25519-public-key",
{"topic": "torrent-abc", "activePeers": 15} "signature": "base64-encoded-signature",
], "message": "claim:alice:1733404800000"
"total": 123,
"limit": 50,
"offset": 0
} }
``` ```
#### `GET /offers/by-topic/:topic?limit=50&bloom=...` **Response:**
Find offers by topic with optional bloom filter exclusion ```json
{
"username": "alice",
"claimedAt": 1733404800000,
"expiresAt": 1765027200000
}
```
**Query Parameters:** **Validation:**
- `limit` (optional): Maximum offers to return (default: 50, max: 200) - Username format: `^[a-z0-9][a-z0-9-]*[a-z0-9]$` (3-32 characters)
- `bloom` (optional): Base64-encoded bloom filter to exclude known peers - 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:** **Response:**
```json ```json
{ {
"topic": "movie-xyz", "username": "alice",
"offers": [ "available": false,
"claimedAt": 1733404800000,
"expiresAt": 1765027200000,
"publicKey": "..."
}
```
#### `GET /usernames/:username/services`
List all services for a username (privacy-preserving)
**Response:**
```json
{
"username": "alice",
"services": [
{ {
"id": "offer-id", "uuid": "abc123",
"peerId": "peer-id", "isPublic": false
"sdp": "v=0...", },
"topics": ["movie-xyz", "hd-content"], {
"expiresAt": 1234567890, "uuid": "def456",
"lastSeen": 1234567890, "isPublic": true,
"hasSecret": true, // Indicates if secret is required to answer "serviceFqn": "com.example.public@1.0.0",
"info": "Looking for peers in EU region" // Public info field (optional) "metadata": { "description": "Public service" }
} }
], ]
"total": 42,
"returned": 10
} }
``` ```
**Notes:** ### Service Management
- `hasSecret`: Boolean flag indicating whether a secret is required to answer this offer. The actual secret is never exposed in public endpoints.
- `info`: Optional public metadata field (max 128 characters) visible to all peers.
#### `GET /peers/:peerId/offers` #### `POST /services`
View all offers from a specific peer Publish a service (requires authentication and username signature)
### Authenticated Endpoints **Headers:**
- `Authorization: Bearer {peerId}:{secret}`
All authenticated endpoints require `Authorization: Bearer {peerId}:{secret}` header. **Request:**
```json
{
"username": "alice",
"serviceFqn": "com.example.chat@1.0.0",
"sdp": "v=0...",
"ttl": 300000,
"isPublic": false,
"metadata": { "description": "Chat service" },
"signature": "base64-encoded-signature",
"message": "publish:alice:com.example.chat@1.0.0:1733404800000"
}
```
**Response:**
```json
{
"serviceId": "uuid-v4",
"uuid": "uuid-v4-for-index",
"offerId": "offer-hash-id",
"expiresAt": 1733405100000
}
```
**Service FQN Format:**
- Service name: Reverse domain notation (e.g., `com.example.chat`)
- Version: Semantic versioning (e.g., `1.0.0`, `2.1.3-beta`)
- Complete FQN: `service-name@version` (e.g., `com.example.chat@1.0.0`)
**Validation:**
- Service name pattern: `^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$`
- Length: 3-128 characters
- Version pattern: `^[0-9]+\.[0-9]+\.[0-9]+(-[a-z0-9.-]+)?$`
#### `GET /services/:uuid`
Get service details by UUID
**Response:**
```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` #### `POST /offers`
Create one or more offers Create one or more offers (requires authentication)
**Headers:**
- `Authorization: Bearer {peerId}:{secret}`
**Request:** **Request:**
```json ```json
@@ -140,19 +250,12 @@ Create one or more offers
"offers": [ "offers": [
{ {
"sdp": "v=0...", "sdp": "v=0...",
"topics": ["movie-xyz", "hd-content"], "ttl": 300000
"ttl": 300000,
"secret": "my-secret-password", // Optional: protect offer (max 128 chars)
"info": "Looking for peers in EU region" // Optional: public info (max 128 chars)
} }
] ]
} }
``` ```
**Notes:**
- `secret` (optional): Protect the offer with a secret. Answerers must provide the correct secret to connect.
- `info` (optional): Public metadata visible to all peers (max 128 characters). Useful for describing the offer or connection requirements.
#### `GET /offers/mine` #### `GET /offers/mine`
List all offers owned by authenticated peer List all offers owned by authenticated peer
@@ -168,14 +271,10 @@ Answer an offer (locks it to answerer)
**Request:** **Request:**
```json ```json
{ {
"sdp": "v=0...", "sdp": "v=0..."
"secret": "my-secret-password" // Required if offer is protected
} }
``` ```
**Notes:**
- `secret` (optional): Required if the offer was created with a secret. Must match the offer's secret.
#### `GET /offers/answers` #### `GET /offers/answers`
Poll for answers to your offers Poll for answers to your offers
@@ -201,13 +300,62 @@ Environment variables:
| `PORT` | `3000` | Server port (Node.js/Docker) | | `PORT` | `3000` | Server port (Node.js/Docker) |
| `CORS_ORIGINS` | `*` | Comma-separated allowed origins | | `CORS_ORIGINS` | `*` | Comma-separated allowed origins |
| `STORAGE_PATH` | `./rondevu.db` | SQLite database path (use `:memory:` for in-memory) | | `STORAGE_PATH` | `./rondevu.db` | SQLite database path (use `:memory:` for in-memory) |
| `VERSION` | `0.4.0` | Server version (semver) | | `VERSION` | `2.0.0` | Server version (semver) |
| `AUTH_SECRET` | Random 32-byte hex | Secret key for credential encryption | | `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_DEFAULT_TTL` | `300000` | Default offer TTL in ms (5 minutes) |
| `OFFER_MIN_TTL` | `60000` | Minimum offer TTL in ms (1 minute) | | `OFFER_MIN_TTL` | `60000` | Minimum offer TTL in ms (1 minute) |
| `OFFER_MAX_TTL` | `3600000` | Maximum offer TTL in ms (1 hour) | | `OFFER_MAX_TTL` | `3600000` | Maximum offer TTL in ms (1 hour) |
| `MAX_OFFERS_PER_REQUEST` | `10` | Maximum offers per create request | | `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 ## 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", "name": "@xtr-dev/rondevu-server",
"version": "0.1.4", "version": "0.1.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@xtr-dev/rondevu-server", "name": "@xtr-dev/rondevu-server",
"version": "0.1.4", "version": "0.1.5",
"dependencies": { "dependencies": {
"@hono/node-server": "^1.19.6", "@hono/node-server": "^1.19.6",
"@noble/ed25519": "^3.0.0",
"better-sqlite3": "^12.4.1", "better-sqlite3": "^12.4.1",
"hono": "^4.10.4" "hono": "^4.10.4"
}, },
@@ -523,6 +524,15 @@
"@jridgewell/sourcemap-codec": "^1.4.10" "@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": { "node_modules/@tsconfig/node10": {
"version": "1.0.12", "version": "1.0.12",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",

View File

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

View File

@@ -3,12 +3,11 @@ import { cors } from 'hono/cors';
import { Storage } from './storage/types.ts'; import { Storage } from './storage/types.ts';
import { Config } from './config.ts'; import { Config } from './config.ts';
import { createAuthMiddleware, getAuthenticatedPeerId } from './middleware/auth.ts'; import { createAuthMiddleware, getAuthenticatedPeerId } from './middleware/auth.ts';
import { generatePeerId, encryptPeerId } from './crypto.ts'; import { generatePeerId, encryptPeerId, validateUsernameClaim, validateServicePublish, validateServiceFqn } from './crypto.ts';
import { parseBloomFilter } from './bloom.ts';
import type { Context } from 'hono'; 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) { export function createApp(storage: Storage, config: Config) {
const app = new Hono(); const app = new Hono();
@@ -16,18 +15,15 @@ export function createApp(storage: Storage, config: Config) {
// Create auth middleware // Create auth middleware
const authMiddleware = createAuthMiddleware(config.authSecret); const authMiddleware = createAuthMiddleware(config.authSecret);
// Enable CORS with dynamic origin handling // Enable CORS
app.use('/*', cors({ app.use('/*', cors({
origin: (origin) => { origin: (origin) => {
// If no origin restrictions (wildcard), allow any origin
if (config.corsOrigins.length === 1 && config.corsOrigins[0] === '*') { if (config.corsOrigins.length === 1 && config.corsOrigins[0] === '*') {
return origin; return origin;
} }
// Otherwise check if origin is in allowed list
if (config.corsOrigins.includes(origin)) { if (config.corsOrigins.includes(origin)) {
return origin; return origin;
} }
// Default to first allowed origin
return config.corsOrigins[0]; return config.corsOrigins[0];
}, },
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
@@ -37,21 +33,23 @@ export function createApp(storage: Storage, config: Config) {
credentials: true, credentials: true,
})); }));
// ===== General Endpoints =====
/** /**
* GET / * GET /
* Returns server version information * Returns server information
*/ */
app.get('/', (c) => { app.get('/', (c) => {
return c.json({ return c.json({
version: config.version, version: config.version,
name: 'Rondevu', name: 'Rondevu',
description: 'Topic-based peer discovery and signaling server' description: 'DNS-like WebRTC signaling with username claiming and service discovery'
}); });
}); });
/** /**
* GET /health * GET /health
* Health check endpoint with version * Health check endpoint
*/ */
app.get('/health', (c) => { app.get('/health', (c) => {
return c.json({ return c.json({
@@ -63,40 +61,11 @@ export function createApp(storage: Storage, config: Config) {
/** /**
* POST /register * POST /register
* Register a new peer and receive credentials * Register a new peer
* Accepts optional peerId in request body for custom peer IDs
*/ */
app.post('/register', async (c) => { app.post('/register', async (c) => {
try { try {
let peerId: string; const peerId = generatePeerId();
// Check if custom peer ID is provided
const body = await c.req.json().catch(() => ({}));
const customPeerId = body.peerId;
if (customPeerId !== undefined) {
// Validate custom peer ID
if (typeof customPeerId !== 'string' || customPeerId.length === 0) {
return c.json({ error: 'Peer ID must be a non-empty string' }, 400);
}
if (customPeerId.length > 128) {
return c.json({ error: 'Peer ID must be 128 characters or less' }, 400);
}
// Check if peer ID is already in use by checking for active offers
const existingOffers = await storage.getOffersByPeerId(customPeerId);
if (existingOffers.length > 0) {
return c.json({ error: 'Peer ID is already in use' }, 409);
}
peerId = customPeerId;
} else {
// Generate new peer ID
peerId = generatePeerId();
}
// Encrypt peer ID with server secret (async operation)
const secret = await encryptPeerId(peerId, config.authSecret); const secret = await encryptPeerId(peerId, config.authSecret);
return c.json({ return c.json({
@@ -109,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 * POST /offers
* Creates one or more offers with topics * Create offers (direct, no service - for testing/advanced users)
* Requires authentication
*/ */
app.post('/offers', authMiddleware, async (c) => { app.post('/offers', authMiddleware, async (c) => {
try { try {
@@ -124,230 +413,56 @@ export function createApp(storage: Storage, config: Config) {
} }
if (offers.length > config.maxOffersPerRequest) { 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); const peerId = getAuthenticatedPeerId(c);
// Validate and prepare offers // Validate and prepare offers
const offerRequests = []; const validated = offers.map((offer: any) => {
for (const offer of offers) { const { sdp, ttl, secret } = offer;
// Validate SDP
if (!offer.sdp || typeof offer.sdp !== 'string') { if (typeof sdp !== 'string' || sdp.length === 0) {
return c.json({ error: 'Each offer must have an sdp field' }, 400); throw new Error('Invalid SDP in offer');
} }
if (offer.sdp.length > 65536) { if (sdp.length > 64 * 1024) {
return c.json({ error: 'SDP must be 64KB or less' }, 400); throw new Error('SDP too large (max 64KB)');
} }
// Validate secret if provided const offerTtl = Math.min(
if (offer.secret !== undefined) { Math.max(ttl || config.offerDefaultTtl, config.offerMinTtl),
if (typeof offer.secret !== 'string') { config.offerMaxTtl
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);
}
}
// Validate info if provided return {
if (offer.info !== undefined) {
if (typeof offer.info !== 'string') {
return c.json({ error: 'Info must be a string' }, 400);
}
if (offer.info.length > 128) {
return c.json({ error: 'Info must be 128 characters or less' }, 400);
}
}
// 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);
}
if (offer.topics.length > config.maxTopicsPerOffer) {
return c.json({ error: `Too many topics. Maximum ${config.maxTopicsPerOffer} per offer` }, 400);
}
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({
id: offer.id,
peerId, peerId,
sdp: offer.sdp, sdp,
topics: offer.topics, expiresAt: Date.now() + offerTtl,
expiresAt: Date.now() + ttl, secret: secret ? String(secret).substring(0, 128) : undefined
secret: offer.secret, };
info: offer.info, });
});
}
// Create offers const created = await storage.createOffers(validated);
const createdOffers = await storage.createOffers(offerRequests);
// Return simplified response
return c.json({ return c.json({
offers: createdOffers.map(o => ({ offers: created.map(offer => ({
id: o.id, id: offer.id,
peerId: o.peerId, peerId: offer.peerId,
topics: o.topics, expiresAt: offer.expiresAt,
expiresAt: o.expiresAt createdAt: offer.createdAt,
hasSecret: !!offer.secret
})) }))
}, 200); }, 201);
} catch (err) { } catch (err: any) {
console.error('Error creating offers:', err); console.error('Error creating offers:', err);
return c.json({ error: 'Internal server error' }, 500); return c.json({ error: err.message || '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
info: o.info // Public info field
})),
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
info: o.info // Public info field
})),
topics: Array.from(topicsSet)
}, 200);
} catch (err) {
console.error('Error fetching peer offers:', err);
return c.json({ error: 'Internal server error' }, 500);
} }
}); });
/** /**
* GET /offers/mine * GET /offers/mine
* List all offers owned by authenticated peer * Get authenticated peer's offers
* Requires authentication
*/ */
app.get('/offers/mine', authMiddleware, async (c) => { app.get('/offers/mine', authMiddleware, async (c) => {
try { try {
@@ -355,30 +470,26 @@ export function createApp(storage: Storage, config: Config) {
const offers = await storage.getOffersByPeerId(peerId); const offers = await storage.getOffersByPeerId(peerId);
return c.json({ return c.json({
peerId, offers: offers.map(offer => ({
offers: offers.map(o => ({ id: offer.id,
id: o.id, sdp: offer.sdp,
sdp: o.sdp, createdAt: offer.createdAt,
topics: o.topics, expiresAt: offer.expiresAt,
createdAt: o.createdAt, lastSeen: offer.lastSeen,
expiresAt: o.expiresAt, hasSecret: !!offer.secret,
lastSeen: o.lastSeen, answererPeerId: offer.answererPeerId,
secret: o.secret, // Owner can see the secret answered: !!offer.answererPeerId
info: o.info, // Owner can see the info
answererPeerId: o.answererPeerId,
answeredAt: o.answeredAt
})) }))
}, 200); }, 200);
} catch (err) { } catch (err) {
console.error('Error fetching own offers:', err); console.error('Error getting offers:', err);
return c.json({ error: 'Internal server error' }, 500); return c.json({ error: 'Internal server error' }, 500);
} }
}); });
/** /**
* DELETE /offers/:offerId * DELETE /offers/:offerId
* Delete a specific offer * Delete an offer
* Requires authentication and ownership
*/ */
app.delete('/offers/:offerId', authMiddleware, async (c) => { app.delete('/offers/:offerId', authMiddleware, async (c) => {
try { try {
@@ -388,10 +499,10 @@ export function createApp(storage: Storage, config: Config) {
const deleted = await storage.deleteOffer(offerId, peerId); const deleted = await storage.deleteOffer(offerId, peerId);
if (!deleted) { 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) { } catch (err) {
console.error('Error deleting offer:', err); console.error('Error deleting offer:', err);
return c.json({ error: 'Internal server error' }, 500); return c.json({ error: 'Internal server error' }, 500);
@@ -400,40 +511,35 @@ export function createApp(storage: Storage, config: Config) {
/** /**
* POST /offers/:offerId/answer * POST /offers/:offerId/answer
* Answer a specific offer (locks it to answerer) * Answer an offer
* Requires authentication
*/ */
app.post('/offers/:offerId/answer', authMiddleware, async (c) => { app.post('/offers/:offerId/answer', authMiddleware, async (c) => {
try { try {
const offerId = c.req.param('offerId'); const offerId = c.req.param('offerId');
const peerId = getAuthenticatedPeerId(c);
const body = await c.req.json(); const body = await c.req.json();
const { sdp, secret } = body; const { sdp, secret } = body;
if (!sdp || typeof sdp !== 'string') { if (!sdp) {
return c.json({ error: 'Missing or invalid required parameter: sdp' }, 400); return c.json({ error: 'Missing required parameter: sdp' }, 400);
} }
if (sdp.length > 65536) { if (typeof sdp !== 'string' || sdp.length === 0) {
return c.json({ error: 'SDP must be 64KB or less' }, 400); return c.json({ error: 'Invalid SDP' }, 400);
} }
// Validate secret if provided if (sdp.length > 64 * 1024) {
if (secret !== undefined && typeof secret !== 'string') { return c.json({ error: 'SDP too large (max 64KB)' }, 400);
return c.json({ error: 'Secret must be a string' }, 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) { if (!result.success) {
return c.json({ error: result.error }, 400); return c.json({ error: result.error }, 400);
} }
return c.json({ return c.json({ success: true }, 200);
offerId,
answererId: peerId,
answeredAt: Date.now()
}, 200);
} catch (err) { } catch (err) {
console.error('Error answering offer:', err); console.error('Error answering offer:', err);
return c.json({ error: 'Internal server error' }, 500); return c.json({ error: 'Internal server error' }, 500);
@@ -442,8 +548,7 @@ export function createApp(storage: Storage, config: Config) {
/** /**
* GET /offers/answers * GET /offers/answers
* Poll for answers to all of authenticated peer's offers * Get answers for authenticated peer's offers
* Requires authentication (offerer)
*/ */
app.get('/offers/answers', authMiddleware, async (c) => { app.get('/offers/answers', authMiddleware, async (c) => {
try { try {
@@ -451,57 +556,49 @@ export function createApp(storage: Storage, config: Config) {
const offers = await storage.getAnsweredOffers(peerId); const offers = await storage.getAnsweredOffers(peerId);
return c.json({ return c.json({
answers: offers.map(o => ({ answers: offers.map(offer => ({
offerId: o.id, offerId: offer.id,
answererId: o.answererPeerId, answererId: offer.answererPeerId,
sdp: o.answerSdp, sdp: offer.answerSdp,
answeredAt: o.answeredAt, answeredAt: offer.answeredAt
topics: o.topics
})) }))
}, 200); }, 200);
} catch (err) { } catch (err) {
console.error('Error fetching answers:', err); console.error('Error getting answers:', err);
return c.json({ error: 'Internal server error' }, 500); return c.json({ error: 'Internal server error' }, 500);
} }
}); });
// ===== ICE Candidate Exchange =====
/** /**
* POST /offers/:offerId/ice-candidates * POST /offers/:offerId/ice-candidates
* Post ICE candidates for an offer * Add ICE candidates for an offer
* Requires authentication (must be offerer or answerer)
*/ */
app.post('/offers/:offerId/ice-candidates', authMiddleware, async (c) => { app.post('/offers/:offerId/ice-candidates', authMiddleware, async (c) => {
try { try {
const offerId = c.req.param('offerId'); const offerId = c.req.param('offerId');
const peerId = getAuthenticatedPeerId(c);
const body = await c.req.json(); const body = await c.req.json();
const { candidates } = body; const { candidates } = body;
if (!Array.isArray(candidates) || candidates.length === 0) { 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); const offer = await storage.getOfferById(offerId);
if (!offer) { if (!offer) {
return c.json({ error: 'Offer not found or expired' }, 404); return c.json({ error: 'Offer not found' }, 404);
} }
let role: 'offerer' | 'answerer'; // Determine role
if (offer.peerId === peerId) { const role = offer.peerId === peerId ? 'offerer' : 'answerer';
role = 'offerer';
} else if (offer.answererPeerId === peerId) {
role = 'answerer';
} else {
return c.json({ error: 'Not authorized to post ICE candidates for this offer' }, 403);
}
const added = await storage.addIceCandidates(offerId, peerId, role, candidates); const count = await storage.addIceCandidates(offerId, peerId, role, candidates);
return c.json({ return c.json({ count }, 200);
offerId,
candidatesAdded: added
}, 200);
} catch (err) { } catch (err) {
console.error('Error adding ICE candidates:', err); console.error('Error adding ICE candidates:', err);
return c.json({ error: 'Internal server error' }, 500); return c.json({ error: 'Internal server error' }, 500);
@@ -510,50 +607,34 @@ export function createApp(storage: Storage, config: Config) {
/** /**
* GET /offers/:offerId/ice-candidates * GET /offers/:offerId/ice-candidates
* Poll for ICE candidates from the other peer * Get ICE candidates for an offer
* Requires authentication (must be offerer or answerer)
*/ */
app.get('/offers/:offerId/ice-candidates', authMiddleware, async (c) => { app.get('/offers/:offerId/ice-candidates', authMiddleware, async (c) => {
try { try {
const offerId = c.req.param('offerId'); const offerId = c.req.param('offerId');
const since = c.req.query('since');
const peerId = getAuthenticatedPeerId(c); const peerId = getAuthenticatedPeerId(c);
const sinceParam = c.req.query('since');
const since = sinceParam ? parseInt(sinceParam, 10) : undefined; // Get offer to determine role
// Verify offer exists and caller is offerer or answerer
const offer = await storage.getOfferById(offerId); const offer = await storage.getOfferById(offerId);
if (!offer) { if (!offer) {
return c.json({ error: 'Offer not found or expired' }, 404); return c.json({ error: 'Offer not found' }, 404);
} }
let targetRole: 'offerer' | 'answerer'; // Get candidates for opposite role
if (offer.peerId === peerId) { const targetRole = offer.peerId === peerId ? 'answerer' : 'offerer';
// Offerer wants answerer's candidates const sinceTimestamp = since ? parseInt(since, 10) : undefined;
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);
}
const candidates = await storage.getIceCandidates(offerId, targetRole, since); const candidates = await storage.getIceCandidates(offerId, targetRole, sinceTimestamp);
console.log(`[ICE GET] Found ${candidates.length} candidates for offer ${offerId}, targetRole=${targetRole}, since=${since}`);
return c.json({ return c.json({
offerId,
candidates: candidates.map(c => ({ candidates: candidates.map(c => ({
candidate: c.candidate, candidate: c.candidate,
peerId: c.peerId,
role: c.role,
createdAt: c.createdAt createdAt: c.createdAt
})) }))
}, 200); }, 200);
} catch (err) { } catch (err) {
console.error('Error fetching ICE candidates:', err); console.error('Error getting ICE candidates:', err);
return c.json({ error: 'Internal server error' }, 500); 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; offerMinTtl: number;
cleanupInterval: number; cleanupInterval: number;
maxOffersPerRequest: number; maxOffersPerRequest: number;
maxTopicsPerOffer: number;
} }
/** /**
@@ -45,7 +44,6 @@ export function loadConfig(): Config {
offerMaxTtl: parseInt(process.env.OFFER_MAX_TTL || '86400000', 10), offerMaxTtl: parseInt(process.env.OFFER_MAX_TTL || '86400000', 10),
offerMinTtl: parseInt(process.env.OFFER_MIN_TTL || '60000', 10), offerMinTtl: parseInt(process.env.OFFER_MIN_TTL || '60000', 10),
cleanupInterval: parseInt(process.env.CLEANUP_INTERVAL || '60000', 10), cleanupInterval: parseInt(process.env.CLEANUP_INTERVAL || '60000', 10),
maxOffersPerRequest: parseInt(process.env.MAX_OFFERS_PER_REQUEST || '100', 10), maxOffersPerRequest: parseInt(process.env.MAX_OFFERS_PER_REQUEST || '100', 10)
maxTopicsPerOffer: parseInt(process.env.MAX_TOPICS_PER_OFFER || '50', 10),
}; };
} }

View File

@@ -1,12 +1,29 @@
/** /**
* Crypto utilities for stateless peer authentication * Crypto utilities for stateless peer authentication
* Uses Web Crypto API for compatibility with both Node.js and Cloudflare Workers * 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 ALGORITHM = 'AES-GCM';
const IV_LENGTH = 12; // 96 bits for GCM const IV_LENGTH = 12; // 96 bits for GCM
const KEY_LENGTH = 32; // 256 bits 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) * Generates a random peer ID (16 bytes = 32 hex chars)
*/ */
@@ -147,3 +164,199 @@ export async function validateCredentials(peerId: string, encryptedSecret: strin
return false; 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`, offerMinTtl: `${config.offerMinTtl}ms`,
cleanupInterval: `${config.cleanupInterval}ms`, cleanupInterval: `${config.cleanupInterval}ms`,
maxOffersPerRequest: config.maxOffersPerRequest, maxOffersPerRequest: config.maxOffersPerRequest,
maxTopicsPerOffer: config.maxTopicsPerOffer,
corsOrigins: config.corsOrigins, corsOrigins: config.corsOrigins,
version: config.version, 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'; 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 * D1 storage adapter for rondevu DNS-like system using Cloudflare D1
* NOTE: This implementation is a placeholder and needs to be fully tested
*/ */
export class D1Storage implements Storage { export class D1Storage implements Storage {
private db: D1Database; 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 * This should be run once during setup, not on every request
*/ */
async initializeDatabase(): Promise<void> { async initializeDatabase(): Promise<void> {
await this.db.exec(` await this.db.exec(`
-- WebRTC signaling offers
CREATE TABLE IF NOT EXISTS offers ( CREATE TABLE IF NOT EXISTS offers (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
peer_id TEXT NOT NULL, 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_last_seen ON offers(last_seen);
CREATE INDEX IF NOT EXISTS idx_offers_answerer ON offers(answerer_peer_id); CREATE INDEX IF NOT EXISTS idx_offers_answerer ON offers(answerer_peer_id);
CREATE TABLE IF NOT EXISTS offer_topics ( -- ICE candidates table
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);
CREATE TABLE IF NOT EXISTS ice_candidates ( CREATE TABLE IF NOT EXISTS ice_candidates (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
offer_id TEXT NOT NULL, offer_id TEXT NOT NULL,
peer_id TEXT NOT NULL, peer_id TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('offerer', 'answerer')), 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, created_at INTEGER NOT NULL,
FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE 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_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_peer ON ice_candidates(peer_id);
CREATE INDEX IF NOT EXISTS idx_ice_created ON ice_candidates(created_at); 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[]> { async createOffers(offers: CreateOfferRequest[]): Promise<Offer[]> {
const created: Offer[] = []; const created: Offer[] = [];
// D1 doesn't support true transactions yet, so we do this sequentially // D1 doesn't support true transactions yet, so we do this sequentially
for (const offer of offers) { 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(); const now = Date.now();
// Insert offer
await this.db.prepare(` await this.db.prepare(`
INSERT INTO offers (id, peer_id, sdp, created_at, expires_at, last_seen, secret) INSERT INTO offers (id, peer_id, sdp, created_at, expires_at, last_seen, secret)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
`).bind(id, offer.peerId, offer.sdp, now, offer.expiresAt, now, offer.secret || null).run(); `).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({ created.push({
id, id,
peerId: offer.peerId, peerId: offer.peerId,
sdp: offer.sdp, sdp: offer.sdp,
topics: offer.topics,
createdAt: now, createdAt: now,
expiresAt: offer.expiresAt, expiresAt: offer.expiresAt,
lastSeen: now, lastSeen: now,
@@ -103,33 +147,6 @@ export class D1Storage implements Storage {
return created; 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[]> { async getOffersByPeerId(peerId: string): Promise<Offer[]> {
const result = await this.db.prepare(` const result = await this.db.prepare(`
SELECT * FROM offers SELECT * FROM offers
@@ -141,7 +158,7 @@ export class D1Storage implements Storage {
return []; 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> { async getOfferById(offerId: string): Promise<Offer | null> {
@@ -234,21 +251,20 @@ export class D1Storage implements Storage {
return []; 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( async addIceCandidates(
offerId: string, offerId: string,
peerId: string, peerId: string,
role: 'offerer' | 'answerer', role: 'offerer' | 'answerer',
candidates: any[] candidates: any[]
): Promise<number> { ): 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 // D1 doesn't have transactions, so insert one by one
for (let i = 0; i < candidates.length; i++) { 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(` await this.db.prepare(`
INSERT INTO ice_candidates (offer_id, peer_id, role, candidate, created_at) INSERT INTO ice_candidates (offer_id, peer_id, role, candidate, created_at)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
@@ -256,7 +272,7 @@ export class D1Storage implements Storage {
offerId, offerId,
peerId, peerId,
role, role,
JSON.stringify(candidates[i]), // Store full object as JSON JSON.stringify(candidates[i]),
timestamp timestamp
).run(); ).run();
} }
@@ -283,82 +299,232 @@ export class D1Storage implements Storage {
query += ' ORDER BY created_at ASC'; 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(); const result = await this.db.prepare(query).bind(...params).all();
console.log(`[D1] getIceCandidates result: ${result.results?.length || 0} rows`);
if (!result.results) { if (!result.results) {
return []; return [];
} }
const candidates = result.results.map((row: any) => ({ return result.results.map((row: any) => ({
id: row.id, id: row.id,
offerId: row.offer_id, offerId: row.offer_id,
peerId: row.peer_id, peerId: row.peer_id,
role: row.role, role: row.role,
candidate: JSON.parse(row.candidate), // Parse JSON back to object candidate: JSON.parse(row.candidate),
createdAt: row.created_at, createdAt: row.created_at,
})); }));
if (candidates.length > 0) {
console.log(`[D1] First candidate createdAt: ${candidates[0].createdAt}, since: ${since}`);
}
return candidates;
} }
async getTopics(limit: number, offset: number, startsWith?: string): Promise<{ // ===== Username Management =====
topics: TopicInfo[];
total: number; 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');
}
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(); const now = Date.now();
// Build WHERE clause for startsWith filter // Insert service
const whereClause = startsWith await this.db.prepare(`
? 'o.expires_at > ? AND ot.topic LIKE ?' INSERT INTO services (id, username, service_fqn, offer_id, created_at, expires_at, is_public, metadata)
: 'o.expires_at > ?'; 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 // Touch username to extend expiry
const countQuery = ` await this.touchUsername(request.username);
SELECT COUNT(DISTINCT ot.topic) as count
FROM offer_topics ot
INNER JOIN offers o ON ot.offer_id = o.id
WHERE ${whereClause}
`;
const countStmt = this.db.prepare(countQuery); return {
const countResult = startsWith service: {
? await countStmt.bind(now, startsWithPattern).first() id: serviceId,
: await countStmt.bind(now).first(); 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) if (!result) {
const topicsQuery = ` return null;
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 ?
`;
const topicsStmt = this.db.prepare(topicsQuery); return this.rowToService(result as any);
const topicsResult = startsWith }
? await topicsStmt.bind(now, startsWithPattern, limit, offset).all()
: await topicsStmt.bind(now, limit, offset).all();
const topics = (topicsResult.results || []).map((row: any) => ({ async getServiceByUuid(uuid: string): Promise<Service | null> {
topic: row.topic, const result = await this.db.prepare(`
activePeers: row.active_peers, 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> { async close(): Promise<void> {
@@ -366,22 +532,16 @@ export class D1Storage implements Storage {
// Connections are managed by the Cloudflare Workers runtime // 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> { private rowToOffer(row: any): 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) || [];
return { return {
id: row.id, id: row.id,
peerId: row.peer_id, peerId: row.peer_id,
sdp: row.sdp, sdp: row.sdp,
topics,
createdAt: row.created_at, createdAt: row.created_at,
expiresAt: row.expires_at, expiresAt: row.expires_at,
lastSeen: row.last_seen, lastSeen: row.last_seen,
@@ -391,4 +551,20 @@ export class D1Storage implements Storage {
answeredAt: row.answered_at || undefined, 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 * 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 * PeerID is not included as it's inferred from authentication
* Uses Web Crypto API for compatibility with both Node.js and Cloudflare Workers * Uses Web Crypto API for compatibility with both Node.js and Cloudflare Workers
* *
* @param sdp - The WebRTC SDP offer * @param sdp - The WebRTC SDP offer
* @param topics - Array of topic strings * @returns SHA-256 hash of the SDP content
* @returns SHA-256 hash of the sanitized offer content
*/ */
export async function generateOfferHash( export async function generateOfferHash(sdp: string): Promise<string> {
sdp: string,
topics: string[]
): Promise<string> {
// Sanitize and normalize the offer content // Sanitize and normalize the offer content
// Only include core offer content (not peerId - that's inferred from auth) // Only include core offer content (not peerId - that's inferred from auth)
const sanitizedOffer = { const sanitizedOffer = {
sdp, sdp
topics: [...topics].sort(), // Sort topics for consistency
}; };
// Create non-prettified JSON string // Create non-prettified JSON string

View File

@@ -1,9 +1,22 @@
import Database from 'better-sqlite3'; 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'; 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 * Supports both file-based and in-memory databases
*/ */
export class SQLiteStorage implements Storage { 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 { private initializeDatabase(): void {
this.db.exec(` this.db.exec(`
-- WebRTC signaling offers
CREATE TABLE IF NOT EXISTS offers ( CREATE TABLE IF NOT EXISTS offers (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
peer_id TEXT NOT NULL, 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_last_seen ON offers(last_seen);
CREATE INDEX IF NOT EXISTS idx_offers_answerer ON offers(answerer_peer_id); CREATE INDEX IF NOT EXISTS idx_offers_answerer ON offers(answerer_peer_id);
CREATE TABLE IF NOT EXISTS offer_topics ( -- ICE candidates table
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);
CREATE TABLE IF NOT EXISTS ice_candidates ( CREATE TABLE IF NOT EXISTS ice_candidates (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
offer_id TEXT NOT NULL, offer_id TEXT NOT NULL,
peer_id TEXT NOT NULL, peer_id TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('offerer', 'answerer')), 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, created_at INTEGER NOT NULL,
FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE 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_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_peer ON ice_candidates(peer_id);
CREATE INDEX IF NOT EXISTS idx_ice_created ON ice_candidates(created_at); 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 // Enable foreign keys
this.db.pragma('foreign_keys = ON'); this.db.pragma('foreign_keys = ON');
} }
// ===== Offer Management =====
async createOffers(offers: CreateOfferRequest[]): Promise<Offer[]> { async createOffers(offers: CreateOfferRequest[]): Promise<Offer[]> {
const created: Offer[] = []; const created: Offer[] = [];
@@ -77,7 +132,7 @@ export class SQLiteStorage implements Storage {
const offersWithIds = await Promise.all( const offersWithIds = await Promise.all(
offers.map(async (offer) => ({ offers.map(async (offer) => ({
...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 (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
`); `);
const topicStmt = this.db.prepare(`
INSERT INTO offer_topics (offer_id, topic)
VALUES (?, ?)
`);
for (const offer of offersWithIds) { for (const offer of offersWithIds) {
const now = Date.now(); const now = Date.now();
@@ -107,16 +157,10 @@ export class SQLiteStorage implements Storage {
offer.secret || null offer.secret || null
); );
// Insert topics
for (const topic of offer.topics) {
topicStmt.run(offer.id, topic);
}
created.push({ created.push({
id: offer.id, id: offer.id,
peerId: offer.peerId, peerId: offer.peerId,
sdp: offer.sdp, sdp: offer.sdp,
topics: offer.topics,
createdAt: now, createdAt: now,
expiresAt: offer.expiresAt, expiresAt: offer.expiresAt,
lastSeen: now, lastSeen: now,
@@ -129,30 +173,6 @@ export class SQLiteStorage implements Storage {
return created; 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[]> { async getOffersByPeerId(peerId: string): Promise<Offer[]> {
const stmt = this.db.prepare(` const stmt = this.db.prepare(`
SELECT * FROM offers SELECT * FROM offers
@@ -161,7 +181,7 @@ export class SQLiteStorage implements Storage {
`); `);
const rows = stmt.all(peerId, Date.now()) as any[]; 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> { 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[]; 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( async addIceCandidates(
offerId: string, offerId: string,
peerId: string, peerId: string,
@@ -275,8 +297,8 @@ export class SQLiteStorage implements Storage {
offerId, offerId,
peerId, peerId,
role, role,
JSON.stringify(candidates[i]), // Store full object as JSON JSON.stringify(candidates[i]),
baseTimestamp + i // Ensure unique timestamps to avoid "since" filtering issues baseTimestamp + i
); );
} }
}); });
@@ -312,85 +334,249 @@ export class SQLiteStorage implements Storage {
offerId: row.offer_id, offerId: row.offer_id,
peerId: row.peer_id, peerId: row.peer_id,
role: row.role, role: row.role,
candidate: JSON.parse(row.candidate), // Parse JSON back to object candidate: JSON.parse(row.candidate),
createdAt: row.created_at, createdAt: row.created_at,
})); }));
} }
async getTopics(limit: number, offset: number, startsWith?: string): Promise<{ // ===== Username Management =====
topics: TopicInfo[];
total: number; 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(); const now = Date.now();
// Build WHERE clause for startsWith filter const transaction = this.db.transaction(() => {
const whereClause = startsWith // Insert service
? 'o.expires_at > ? AND ot.topic LIKE ?' const serviceStmt = this.db.prepare(`
: 'o.expires_at > ?'; 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 // Insert service index
const countQuery = ` const indexStmt = this.db.prepare(`
SELECT COUNT(DISTINCT ot.topic) as count INSERT INTO service_index (uuid, service_id, username, service_fqn, created_at, expires_at)
FROM offer_topics ot VALUES (?, ?, ?, ?, ?, ?)
INNER JOIN offers o ON ot.offer_id = o.id `);
WHERE ${whereClause}
`;
const countStmt = this.db.prepare(countQuery); indexStmt.run(
const countParams = startsWith ? [now, startsWithPattern] : [now]; indexUuid,
const countRow = countStmt.get(...countParams) as any; serviceId,
const total = countRow.count; request.username,
request.serviceFqn,
now,
request.expiresAt
);
// Get topics with peer counts (paginated) // Touch username to extend expiry
const topicsQuery = ` this.touchUsername(request.username);
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 ?
`;
const topicsStmt = this.db.prepare(topicsQuery); transaction();
const topicsParams = startsWith
? [now, startsWithPattern, limit, offset]
: [now, limit, offset];
const rows = topicsStmt.all(...topicsParams) as any[];
const topics = rows.map(row => ({ return {
topic: row.topic, service: {
activePeers: row.active_peers, 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> { async close(): Promise<void> {
this.db.close(); 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> { private rowToOffer(row: any): 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);
return { return {
id: row.id, id: row.id,
peerId: row.peer_id, peerId: row.peer_id,
sdp: row.sdp, sdp: row.sdp,
topics,
createdAt: row.created_at, createdAt: row.created_at,
expiresAt: row.expires_at, expiresAt: row.expires_at,
lastSeen: row.last_seen, lastSeen: row.last_seen,
@@ -400,4 +586,20 @@ export class SQLiteStorage implements Storage {
answeredAt: row.answered_at || undefined, 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,16 +1,14 @@
/** /**
* Represents a WebRTC signaling offer with topic-based discovery * Represents a WebRTC signaling offer
*/ */
export interface Offer { export interface Offer {
id: string; id: string;
peerId: string; peerId: string;
sdp: string; sdp: string;
topics: string[];
createdAt: number; createdAt: number;
expiresAt: number; expiresAt: number;
lastSeen: number; lastSeen: number;
secret?: string; secret?: string;
info?: string;
answererPeerId?: string; answererPeerId?: string;
answerSdp?: string; answerSdp?: string;
answeredAt?: number; answeredAt?: number;
@@ -29,14 +27,6 @@ export interface IceCandidate {
createdAt: number; createdAt: number;
} }
/**
* Represents a topic with active peer count
*/
export interface TopicInfo {
topic: string;
activePeers: number;
}
/** /**
* Request to create a new offer * Request to create a new offer
*/ */
@@ -44,17 +34,87 @@ export interface CreateOfferRequest {
id?: string; id?: string;
peerId: string; peerId: string;
sdp: string; sdp: string;
topics: string[];
expiresAt: number; expiresAt: number;
secret?: string; secret?: string;
info?: string;
} }
/** /**
* Storage interface for offer management with topic-based discovery * Represents a claimed username with cryptographic proof
* Implementations can use different backends (SQLite, D1, Memory, etc.) */
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 { export interface Storage {
// ===== Offer Management =====
/** /**
* Creates one or more offers * Creates one or more offers
* @param offers Array of offer creation requests * @param offers Array of offer creation requests
@@ -62,14 +122,6 @@ export interface Storage {
*/ */
createOffers(offers: CreateOfferRequest[]): Promise<Offer[]>; 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 * Retrieves all offers from a specific peer
* @param peerId Peer identifier * @param peerId Peer identifier
@@ -119,6 +171,8 @@ export interface Storage {
*/ */
getAnsweredOffers(offererPeerId: string): Promise<Offer[]>; getAnsweredOffers(offererPeerId: string): Promise<Offer[]>;
// ===== ICE Candidate Management =====
/** /**
* Adds ICE candidates for an offer * Adds ICE candidates for an offer
* @param offerId Offer identifier * @param offerId Offer identifier
@@ -147,18 +201,92 @@ export interface Storage {
since?: number since?: number
): Promise<IceCandidate[]>; ): Promise<IceCandidate[]>;
// ===== Username Management =====
/** /**
* Retrieves topics with active peer counts (paginated) * Claims a username (or refreshes expiry if already owned)
* @param limit Maximum number of topics to return * @param request Username claim request with signature
* @param offset Number of topics to skip * @returns Created/updated username record
* @param startsWith Optional prefix filter - only return topics starting with this string
* @returns Object with topics array and total count
*/ */
getTopics(limit: number, offset: number, startsWith?: string): Promise<{ claimUsername(request: ClaimUsernameRequest): Promise<Username>;
topics: TopicInfo[];
total: number; /**
* 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 * Closes the storage connection and releases resources
*/ */

View File

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