27 Commits

Author SHA1 Message Date
163e1f73d4 fix: update D1 schema to match v0.4.0 service-to-offers relationship
- Add service_id column to offers table
- Remove offer_id column from services table
- Add index for service_id in offers
2025-12-07 22:31:34 +01:00
1d47d47ef7 feat: add database migration for service-to-offers refactor
- Add service_id column to offers table
- Remove offer_id column from services table
- Update VERSION to 0.4.0 in wrangler.toml
2025-12-07 22:28:14 +01:00
1d70cd79e8 feat: refactor to service-based WebRTC signaling endpoints
BREAKING CHANGE: Replace offer-based endpoints with service-based signaling

- Add POST /services/:uuid/answer
- Add GET /services/:uuid/answer
- Add POST /services/:uuid/ice-candidates
- Add GET /services/:uuid/ice-candidates
- Remove all /offers/* endpoints (POST /offers, GET /offers/mine, etc.)
- Server auto-detects peer's offer when offerId is omitted
- Update README with new service-based API documentation
- Bump version to 0.4.0

This change simplifies the API by focusing on services rather than individual offers.
WebRTC signaling (answer/ICE) now operates at the service level, with automatic
offer detection when needed.
2025-12-07 22:17:24 +01:00
2aa1fee4d6 docs: update server README to remove outdated sections
- Remove obsolete POST /index/:username/query endpoint
- Remove non-existent PUT /offers/:offerId/heartbeat endpoint
- Update architecture diagram to reflect semver discovery
- Update database schema to show service-to-offers relationship
2025-12-07 22:07:16 +01:00
d564e2250f docs: Update README with semver matching and offers array 2025-12-07 22:00:40 +01:00
06ec5020f7 0.3.0 2025-12-07 21:59:15 +01:00
5c71f66a26 feat: Add semver-compatible service discovery with privacy
## Breaking Changes

### Removed Endpoints
- Removed GET /users/:username/services (service listing)
- Services are now completely hidden - cannot be enumerated

### Updated Endpoints
- GET /users/:username/services/:fqn now supports semver matching
- Requesting chat@1.0.0 will match chat@1.2.3, chat@1.5.0, etc.
- Will NOT match chat@2.0.0 (different major version)

## New Features

### Semantic Versioning Support
- Compatible version matching following semver rules (^1.0.0)
- Major version must match exactly
- For major version 0, minor must also match (0.x.y is unstable)
- Available version must be >= requested version
- Prerelease versions require exact match

### Privacy Improvements
- All services are now hidden by default
- No way to enumerate or list services for a username
- Must know exact service name to discover

## Implementation

### Server (src/)
- crypto.ts: Added parseVersion(), isVersionCompatible(), parseServiceFqn()
- storage/types.ts: Added findServicesByName() interface method
- storage/sqlite.ts: Implemented findServicesByName() with LIKE query
- storage/d1.ts: Implemented findServicesByName() with LIKE query
- app.ts: Updated GET /:username/services/:fqn with semver matching

### Semver Matching Logic
- Parse requested version: chat@1.0.0 → {name: "chat", version: "1.0.0"}
- Find all services with matching name: chat@*
- Filter to compatible versions using semver rules
- Return first match (most recently created)

## Examples

Request: chat@1.0.0
Matches: chat@1.0.0, chat@1.2.3, chat@1.9.5
Does NOT match: chat@0.9.0, chat@2.0.0, chat@1.0.0-beta

🤖 Generated with Claude Code
2025-12-07 21:56:19 +01:00
ca3db47009 Refactor: Consolidate service/offer architecture
## Breaking Changes

### Server
- Services can now have multiple offers instead of single offer
- POST /users/:username/services accepts `offers` array instead of `sdp`
- GET /users/:username/services/:fqn returns `offers` array in response
- GET /services/:uuid returns `offers` array in response
- Database schema: removed `offer_id` from services table, added `service_id` to offers table
- Added `batchCreateServices()` and `getOffersForService()` methods

### Client
- `PublishServiceOptions` interface: `offers` array instead of `sdp` string
- `Service` interface: `offers` array instead of `offerId` and `sdp`
- `ServiceRequest` interface: `offers` array instead of `sdp`
- RondevuSignaler.setOffer() sends offers array to server
- Updated to extract offerId from first offer in service response

## New Features
- Support for multiple simultaneous offers per service (connection pooling)
- Batch service creation endpoint for reduced server load
- Proper one-to-many relationship between services and offers

## Implementation Details

### Server Changes (src/storage/)
- sqlite.ts: Added service_id column to offers, removed offer_id from services
- d1.ts: Updated to match new interface
- types.ts: Updated interfaces for Service, Offer, CreateServiceRequest
- app.ts: Updated all service endpoints to handle offers array

### Client Changes (src/)
- api.ts: Added OfferRequest and ServiceOffer interfaces
- rondevu-service.ts: Updated PublishServiceOptions to use offers array
- rondevu-signaler.ts: Updated to send/receive offers array

## Migration Notes
- No backwards compatibility - this is a breaking change
- Services published with old API will not work with new server
- Clients must update to new API to work with updated server

🤖 Generated with Claude Code
2025-12-07 21:49:23 +01:00
3efed6e9d2 Fix service reconnection: return available offer from pool
Modified /services/:uuid endpoint to return an available (unanswered)
offer from the service's offer pool instead of always returning the
initial offer. This fixes reconnection failures where clients would
try to answer already-consumed offers.

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

Fixes: "Offer already answered" errors on reconnection attempts

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Applied to production D1 database: rondevu-offers

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-22 17:32:56 +01:00
16 changed files with 2061 additions and 802 deletions

401
README.md
View File

@@ -2,9 +2,9 @@
[![npm version](https://img.shields.io/npm/v/@xtr-dev/rondevu-server)](https://www.npmjs.com/package/@xtr-dev/rondevu-server)
🌐 **Topic-based peer discovery and WebRTC signaling**
🌐 **DNS-like WebRTC signaling with username claiming and service discovery**
Scalable peer-to-peer connection establishment with topic-based discovery, stateless authentication, and complete WebRTC signaling.
Scalable WebRTC signaling server with cryptographic username claiming, service publishing, and privacy-preserving discovery.
**Related repositories:**
- [@xtr-dev/rondevu-client](https://github.com/xtr-dev/rondevu-client) - TypeScript client library ([npm](https://www.npmjs.com/package/@xtr-dev/rondevu-client))
@@ -15,14 +15,28 @@ Scalable peer-to-peer connection establishment with topic-based discovery, state
## Features
- **Topic-Based Discovery**: Tag offers with topics (e.g., torrent infohashes) for efficient peer finding
- **Username Claiming**: Cryptographic username ownership with Ed25519 signatures (365-day validity, auto-renewed on use)
- **Service Publishing**: Package-style naming with semantic versioning (com.example.chat@1.0.0)
- **Privacy-Preserving Discovery**: UUID-based service index prevents enumeration
- **Public/Private Services**: Control service visibility
- **Stateless Authentication**: AES-256-GCM encrypted credentials, no server-side sessions
- **Protected Offers**: Optional secret field for access-controlled peer connections
- **Bloom Filters**: Client-side peer exclusion for efficient discovery
- **Multi-Offer Support**: Create multiple offers per peer simultaneously
- **Complete WebRTC Signaling**: Offer/answer exchange and ICE candidate relay
- **Dual Storage**: SQLite (Node.js/Docker) and Cloudflare D1 (Workers) backends
## Architecture
```
Username Claiming → Service Publishing → Service Discovery → WebRTC Connection
alice claims "alice" with Ed25519 signature
alice publishes com.example.chat@1.0.0 with multiple offers → receives UUID abc123
bob requests alice/com.example.chat@1.0.0 → gets compatible service with available offer
WebRTC connection established via offer/answer exchange
```
## Quick Start
**Node.js:**
@@ -32,7 +46,7 @@ npm install && npm start
**Docker:**
```bash
docker build -t rondevu . && docker run -p 3000:3000 -e STORAGE_PATH=:memory: rondevu
docker build -t rondevu . && docker run -p 3000:3000 -e STORAGE_PATH=:memory: -e AUTH_SECRET=$(openssl rand -hex 32) rondevu
```
**Cloudflare Workers:**
@@ -53,16 +67,7 @@ Health check endpoint with version
#### `POST /register`
Register a new peer and receive credentials (peerId + secret)
**Request (optional):**
```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.
Generates a cryptographically random 128-bit peer ID.
**Response:**
```json
@@ -72,121 +77,254 @@ Register a new peer and receive credentials (peerId + secret)
}
```
#### `GET /topics?limit=50&offset=0`
List all topics with active peer counts (paginated)
### User Management (RESTful)
**Query Parameters:**
- `limit` (optional): Maximum number of topics to return (default: 50, max: 200)
- `offset` (optional): Number of topics to skip (default: 0)
#### `GET /users/:username`
Check username availability and claim status
**Response:**
```json
{
"topics": [
{"topic": "movie-xyz", "activePeers": 42},
{"topic": "torrent-abc", "activePeers": 15}
],
"total": 123,
"limit": 50,
"offset": 0
"username": "alice",
"available": false,
"claimedAt": 1733404800000,
"expiresAt": 1765027200000,
"publicKey": "..."
}
```
#### `GET /offers/by-topic/:topic?limit=50&bloom=...`
Find offers by topic with optional bloom filter exclusion
**Query Parameters:**
- `limit` (optional): Maximum offers to return (default: 50, max: 200)
- `bloom` (optional): Base64-encoded bloom filter to exclude known peers
**Response:**
```json
{
"topic": "movie-xyz",
"offers": [
{
"id": "offer-id",
"peerId": "peer-id",
"sdp": "v=0...",
"topics": ["movie-xyz", "hd-content"],
"expiresAt": 1234567890,
"lastSeen": 1234567890,
"hasSecret": true // Indicates if secret is required to answer
}
],
"total": 42,
"returned": 10
}
```
**Notes:**
- `hasSecret`: Boolean flag indicating whether a secret is required to answer this offer. The actual secret is never exposed in public endpoints.
#### `GET /peers/:peerId/offers`
View all offers from a specific peer
### Authenticated Endpoints
All authenticated endpoints require `Authorization: Bearer {peerId}:{secret}` header.
#### `POST /offers`
Create one or more offers
#### `POST /users/:username`
Claim a username with cryptographic proof
**Request:**
```json
{
"offers": [
{
"publicKey": "base64-encoded-ed25519-public-key",
"signature": "base64-encoded-signature",
"message": "claim:alice:1733404800000"
}
```
**Response:**
```json
{
"username": "alice",
"claimedAt": 1733404800000,
"expiresAt": 1765027200000
}
```
**Validation:**
- Username format: `^[a-z0-9][a-z0-9-]*[a-z0-9]$` (3-32 characters)
- Signature must be valid Ed25519 signature
- Timestamp must be within 5 minutes (replay protection)
- Expires after 365 days, auto-renewed on use
#### `GET /users/:username/services/:fqn`
Get service by username and FQN with semver-compatible matching
**Semver Matching:**
- Requesting `chat@1.0.0` matches any `1.x.x` version
- Major version must match exactly (`chat@1.0.0` will NOT match `chat@2.0.0`)
- For major version 0, minor must also match (`0.1.0` will NOT match `0.2.0`)
- Returns the most recently published compatible version
**Response:**
```json
{
"uuid": "abc123",
"serviceId": "service-id",
"username": "alice",
"serviceFqn": "chat.app@1.0.0",
"offerId": "offer-hash",
"sdp": "v=0...",
"topics": ["movie-xyz", "hd-content"],
"isPublic": true,
"metadata": {},
"createdAt": 1733404800000,
"expiresAt": 1733405100000
}
```
**Note:** Returns a single available offer from the service. If all offers are in use, returns 503.
### Service Management (RESTful)
#### `POST /users/:username/services`
Publish a service with multiple offers (requires authentication and username signature)
**Headers:**
- `Authorization: Bearer {peerId}:{secret}`
**Request:**
```json
{
"serviceFqn": "com.example.chat@1.0.0",
"offers": [
{ "sdp": "v=0..." },
{ "sdp": "v=0..." }
],
"ttl": 300000,
"secret": "my-secret-password" // Optional: protect offer (max 128 chars)
}
]
"isPublic": false,
"metadata": { "description": "Chat service" },
"signature": "base64-encoded-signature",
"message": "publish:alice:com.example.chat@1.0.0:1733404800000"
}
```
**Notes:**
- `secret` (optional): Protect the offer with a secret. Answerers must provide the correct secret to connect.
#### `GET /offers/mine`
List all offers owned by authenticated peer
#### `PUT /offers/:offerId/heartbeat`
Update last_seen timestamp for an offer
#### `DELETE /offers/:offerId`
Delete a specific offer
#### `POST /offers/:offerId/answer`
Answer an offer (locks it to answerer)
**Request:**
**Response (Full service details):**
```json
{
"uuid": "uuid-v4-for-index",
"serviceId": "uuid-v4",
"username": "alice",
"serviceFqn": "com.example.chat@1.0.0",
"offers": [
{
"offerId": "offer-hash-1",
"sdp": "v=0...",
"secret": "my-secret-password" // Required if offer is protected
"createdAt": 1733404800000,
"expiresAt": 1733405100000
},
{
"offerId": "offer-hash-2",
"sdp": "v=0...",
"createdAt": 1733404800000,
"expiresAt": 1733405100000
}
],
"isPublic": false,
"metadata": { "description": "Chat service" },
"createdAt": 1733404800000,
"expiresAt": 1733405100000
}
```
**Notes:**
- `secret` (optional): Required if the offer was created with a secret. Must match the offer's secret.
**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`)
#### `GET /offers/answers`
Poll for answers to your offers
**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.-]+)?$`
#### `POST /offers/:offerId/ice-candidates`
Post ICE candidates for an offer
#### `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 /users/:username/services/:fqn`
Unpublish a service (requires authentication and ownership)
**Headers:**
- `Authorization: Bearer {peerId}:{secret}`
**Request:**
```json
{
"candidates": ["candidate:1 1 UDP..."]
"username": "alice"
}
```
#### `GET /offers/:offerId/ice-candidates?since=1234567890`
Get ICE candidates from the other peer
### WebRTC Signaling (Service-Based)
#### `POST /services/:uuid/answer`
Answer a service offer (requires authentication)
**Headers:**
- `Authorization: Bearer {peerId}:{secret}`
**Request:**
```json
{
"sdp": "v=0..."
}
```
**Response:**
```json
{
"success": true,
"offerId": "offer-hash"
}
```
#### `GET /services/:uuid/answer`
Get answer for a service (offerer polls this)
**Headers:**
- `Authorization: Bearer {peerId}:{secret}`
**Response:**
```json
{
"offerId": "offer-hash",
"answererId": "answerer-peer-id",
"sdp": "v=0...",
"answeredAt": 1733404800000
}
```
**Note:** Returns 404 if not yet answered
#### `POST /services/:uuid/ice-candidates`
Post ICE candidates for a service (requires authentication)
**Headers:**
- `Authorization: Bearer {peerId}:{secret}`
**Request:**
```json
{
"candidates": ["candidate:1 1 UDP..."],
"offerId": "optional-offer-id"
}
```
**Response:**
```json
{
"count": 1,
"offerId": "offer-hash"
}
```
**Note:** If `offerId` is omitted, the server will auto-detect the peer's offer
#### `GET /services/:uuid/ice-candidates?since=1234567890&offerId=optional-offer-id`
Get ICE candidates from the other peer (requires authentication)
**Headers:**
- `Authorization: Bearer {peerId}:{secret}`
**Response:**
```json
{
"candidates": [
{
"candidate": "candidate:1 1 UDP...",
"createdAt": 1733404800000
}
],
"offerId": "offer-hash"
}
```
**Note:** Returns candidates from the opposite role (offerer gets answerer candidates and vice versa)
## Configuration
@@ -197,13 +335,70 @@ Environment variables:
| `PORT` | `3000` | Server port (Node.js/Docker) |
| `CORS_ORIGINS` | `*` | Comma-separated allowed origins |
| `STORAGE_PATH` | `./rondevu.db` | SQLite database path (use `:memory:` for in-memory) |
| `VERSION` | `0.4.0` | Server version (semver) |
| `AUTH_SECRET` | Random 32-byte hex | Secret key for credential encryption |
| `VERSION` | `2.0.0` | Server version (semver) |
| `AUTH_SECRET` | Random 32-byte hex | Secret key for credential encryption (required for production) |
| `OFFER_DEFAULT_TTL` | `300000` | Default offer TTL in ms (5 minutes) |
| `OFFER_MIN_TTL` | `60000` | Minimum offer TTL in ms (1 minute) |
| `OFFER_MAX_TTL` | `3600000` | Maximum offer TTL in ms (1 hour) |
| `MAX_OFFERS_PER_REQUEST` | `10` | Maximum offers per create request |
| `MAX_TOPICS_PER_OFFER` | `20` | Maximum topics per offer |
## Database Schema
### usernames
- `username` (PK): Claimed username
- `public_key`: Ed25519 public key (base64)
- `claimed_at`: Claim timestamp
- `expires_at`: Expiry timestamp (365 days)
- `last_used`: Last activity timestamp
- `metadata`: Optional JSON metadata
### services
- `id` (PK): Service ID (UUID)
- `username` (FK): Owner username
- `service_fqn`: Fully qualified name (com.example.chat@1.0.0)
- `is_public`: Public/private flag
- `metadata`: JSON metadata
- `created_at`, `expires_at`: Timestamps
### offers
- `id` (PK): Offer ID (hash of SDP)
- `peer_id` (FK): Owner peer ID
- `service_id` (FK): Optional link to service (null for standalone offers)
- `sdp`: WebRTC offer SDP
- `answerer_peer_id`: Peer ID of answerer (null until answered)
- `answer_sdp`: WebRTC answer SDP (null until answered)
- `created_at`, `expires_at`, `last_seen`: Timestamps
### service_index (privacy layer)
- `uuid` (PK): Random UUID for discovery
- `service_id` (FK): Links to service
- `username`, `service_fqn`: Denormalized for performance
## Security
### Username Claiming
- **Algorithm**: Ed25519 signatures
- **Message Format**: `claim:{username}:{timestamp}`
- **Replay Protection**: Timestamp must be within 5 minutes
- **Key Management**: Private keys never leave the client
### Service Publishing
- **Ownership Verification**: Every publish requires username signature
- **Message Format**: `publish:{username}:{serviceFqn}:{timestamp}`
- **Auto-Renewal**: Publishing a service extends username expiry
### Privacy
- **Private Services**: Only UUID exposed, FQN hidden
- **Public Services**: FQN and metadata visible
- **No Enumeration**: Cannot list all services without knowing FQN
## Migration from V1
V2 is a **breaking change** that removes topic-based discovery. See [MIGRATION.md](../MIGRATION.md) for detailed migration guide.
**Key Changes:**
- ❌ Removed: Topic-based discovery, bloom filters, public peer listings
- ✅ Added: Username claiming, service publishing, UUID-based privacy
## License

View File

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

View File

@@ -0,0 +1,40 @@
-- V0.4.0 Migration: Refactor service-to-offer relationship
-- Change from one-to-one (service has offer_id) to one-to-many (offer has service_id)
-- Step 1: Add service_id column to offers table
ALTER TABLE offers ADD COLUMN service_id TEXT;
-- Step 2: Create new services table without offer_id
CREATE TABLE services_new (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
service_fqn 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,
UNIQUE(username, service_fqn)
);
-- Step 3: Copy data from old services table (if any exists)
INSERT INTO services_new (id, username, service_fqn, created_at, expires_at, is_public, metadata)
SELECT id, username, service_fqn, created_at, expires_at, is_public, metadata
FROM services;
-- Step 4: Drop old services table
DROP TABLE services;
-- Step 5: Rename new table to services
ALTER TABLE services_new RENAME TO services;
-- Step 6: Recreate indexes
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);
-- Step 7: Add index for service_id in offers
CREATE INDEX IF NOT EXISTS idx_offers_service ON offers(service_id);
-- Step 8: Add foreign key constraint (D1 doesn't enforce FK in ALTER, but good for documentation)
-- FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE

14
package-lock.json generated
View File

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

View File

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

View File

@@ -3,12 +3,12 @@ import { cors } from 'hono/cors';
import { Storage } from './storage/types.ts';
import { Config } from './config.ts';
import { createAuthMiddleware, getAuthenticatedPeerId } from './middleware/auth.ts';
import { generatePeerId, encryptPeerId } from './crypto.ts';
import { parseBloomFilter } from './bloom.ts';
import { generatePeerId, encryptPeerId, validateUsernameClaim, validateServicePublish, validateServiceFqn, parseServiceFqn, isVersionCompatible } from './crypto.ts';
import type { Context } from 'hono';
/**
* Creates the Hono application with topic-based WebRTC signaling endpoints
* Creates the Hono application with username and service-based WebRTC signaling
* RESTful API design - v0.11.0
*/
export function createApp(storage: Storage, config: Config) {
const app = new Hono();
@@ -16,18 +16,15 @@ export function createApp(storage: Storage, config: Config) {
// Create auth middleware
const authMiddleware = createAuthMiddleware(config.authSecret);
// Enable CORS with dynamic origin handling
// Enable CORS
app.use('/*', cors({
origin: (origin) => {
// If no origin restrictions (wildcard), allow any origin
if (config.corsOrigins.length === 1 && config.corsOrigins[0] === '*') {
return origin;
}
// Otherwise check if origin is in allowed list
if (config.corsOrigins.includes(origin)) {
return origin;
}
// Default to first allowed origin
return config.corsOrigins[0];
},
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
@@ -37,21 +34,23 @@ export function createApp(storage: Storage, config: Config) {
credentials: true,
}));
// ===== General Endpoints =====
/**
* GET /
* Returns server version information
* Returns server information
*/
app.get('/', (c) => {
return c.json({
version: config.version,
name: 'Rondevu',
description: 'Topic-based peer discovery and signaling server'
description: 'DNS-like WebRTC signaling with username claiming and service discovery'
});
});
/**
* GET /health
* Health check endpoint with version
* Health check endpoint
*/
app.get('/health', (c) => {
return c.json({
@@ -63,40 +62,11 @@ export function createApp(storage: Storage, config: Config) {
/**
* POST /register
* Register a new peer and receive credentials
* Accepts optional peerId in request body for custom peer IDs
* Register a new peer
*/
app.post('/register', async (c) => {
try {
let peerId: string;
// 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 peerId = generatePeerId();
const secret = await encryptPeerId(peerId, config.authSecret);
return c.json({
@@ -109,437 +79,566 @@ export function createApp(storage: Storage, config: Config) {
}
});
// ===== User Management (RESTful) =====
/**
* POST /offers
* Creates one or more offers with topics
* Requires authentication
* GET /users/:username
* Check if username is available or get claim info
*/
app.post('/offers', authMiddleware, async (c) => {
app.get('/users/:username', async (c) => {
try {
const username = c.req.param('username');
const claimed = await storage.getUsername(username);
if (!claimed) {
return c.json({
username,
available: true
}, 200);
}
return c.json({
username: claimed.username,
available: false,
claimedAt: claimed.claimedAt,
expiresAt: claimed.expiresAt,
publicKey: claimed.publicKey
}, 200);
} catch (err) {
console.error('Error checking username:', err);
return c.json({ error: 'Internal server error' }, 500);
}
});
/**
* POST /users/:username
* Claim a username with cryptographic proof
*/
app.post('/users/:username', async (c) => {
try {
const username = c.req.param('username');
const body = await c.req.json();
const { offers } = body;
const { publicKey, signature, message } = body;
if (!Array.isArray(offers) || offers.length === 0) {
return c.json({ error: 'Missing or invalid required parameter: offers (must be non-empty array)' }, 400);
if (!publicKey || !signature || !message) {
return c.json({ error: 'Missing required parameters: publicKey, signature, message' }, 400);
}
if (offers.length > config.maxOffersPerRequest) {
return c.json({ error: `Too many offers. Maximum ${config.maxOffersPerRequest} per request` }, 400);
// Validate claim
const validation = await validateUsernameClaim(username, publicKey, signature, message);
if (!validation.valid) {
return c.json({ error: validation.error }, 400);
}
const peerId = getAuthenticatedPeerId(c);
// Attempt to claim username
try {
const claimed = await storage.claimUsername({
username,
publicKey,
signature,
message
});
// Validate and prepare offers
const offerRequests = [];
return c.json({
username: claimed.username,
claimedAt: claimed.claimedAt,
expiresAt: claimed.expiresAt
}, 201);
} catch (err: any) {
if (err.message?.includes('already claimed')) {
return c.json({ error: 'Username already claimed by different public key' }, 409);
}
throw err;
}
} catch (err) {
console.error('Error claiming username:', err);
return c.json({ error: 'Internal server error' }, 500);
}
});
/**
* GET /users/:username/services/:fqn
* Get service by username and FQN with semver-compatible matching
*/
app.get('/users/:username/services/:fqn', async (c) => {
try {
const username = c.req.param('username');
const serviceFqn = decodeURIComponent(c.req.param('fqn'));
// Parse the requested FQN
const parsed = parseServiceFqn(serviceFqn);
if (!parsed) {
return c.json({ error: 'Invalid service FQN format' }, 400);
}
const { serviceName, version: requestedVersion } = parsed;
// Find all services with matching service name
const matchingServices = await storage.findServicesByName(username, serviceName);
if (matchingServices.length === 0) {
return c.json({ error: 'Service not found' }, 404);
}
// Filter to compatible versions
const compatibleServices = matchingServices.filter(service => {
const serviceParsed = parseServiceFqn(service.serviceFqn);
if (!serviceParsed) return false;
return isVersionCompatible(requestedVersion, serviceParsed.version);
});
if (compatibleServices.length === 0) {
return c.json({
error: 'No compatible version found',
message: `Requested ${serviceFqn}, but no compatible versions available`
}, 404);
}
// Use the first compatible service (most recently created)
const service = compatibleServices[0];
// Get the UUID for this service
const uuid = await storage.queryService(username, service.serviceFqn);
if (!uuid) {
return c.json({ error: 'Service index not found' }, 500);
}
// Get all offers for this service
const serviceOffers = await storage.getOffersForService(service.id);
if (serviceOffers.length === 0) {
return c.json({ error: 'No offers found for this service' }, 404);
}
// Find an unanswered offer
const availableOffer = serviceOffers.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({
uuid: uuid,
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);
}
});
/**
* POST /users/:username/services
* Publish a service with one or more offers (RESTful endpoint)
*/
app.post('/users/:username/services', authMiddleware, async (c) => {
let serviceFqn: string | undefined;
let createdOffers: any[] = [];
try {
const username = c.req.param('username');
const body = await c.req.json();
serviceFqn = body.serviceFqn;
const { offers, ttl, isPublic, metadata, signature, message } = body;
if (!serviceFqn || !offers || !Array.isArray(offers) || offers.length === 0) {
return c.json({ error: 'Missing required parameters: serviceFqn, offers (must be non-empty array)' }, 400);
}
// Validate 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 all offers
for (const offer of offers) {
// Validate SDP
if (!offer.sdp || typeof offer.sdp !== 'string') {
return c.json({ error: 'Each offer must have an sdp field' }, 400);
if (!offer.sdp || typeof offer.sdp !== 'string' || offer.sdp.length === 0) {
return c.json({ error: 'Invalid SDP in offers array' }, 400);
}
if (offer.sdp.length > 65536) {
return c.json({ error: 'SDP must be 64KB or less' }, 400);
}
// Validate secret if provided
if (offer.secret !== undefined) {
if (typeof offer.secret !== 'string') {
return c.json({ error: 'Secret must be a string' }, 400);
}
if (offer.secret.length > 128) {
return c.json({ error: 'Secret must be 128 characters or less' }, 400);
if (offer.sdp.length > 64 * 1024) {
return c.json({ error: 'SDP too large (max 64KB)' }, 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);
}
// Calculate expiry
const peerId = getAuthenticatedPeerId(c);
const offerTtl = Math.min(
Math.max(ttl || config.offerDefaultTtl, config.offerMinTtl),
config.offerMaxTtl
);
const expiresAt = Date.now() + offerTtl;
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,
// Prepare offer requests
const offerRequests = offers.map(offer => ({
peerId,
sdp: offer.sdp,
topics: offer.topics,
expiresAt: Date.now() + ttl,
secret: offer.secret,
});
}
expiresAt
}));
// Create offers
const createdOffers = await storage.createOffers(offerRequests);
// Return simplified response
return c.json({
offers: createdOffers.map(o => ({
id: o.id,
peerId: o.peerId,
topics: o.topics,
expiresAt: o.expiresAt
}))
}, 200);
} catch (err) {
console.error('Error creating offers:', err);
return c.json({ error: 'Internal server error' }, 500);
}
// Create service with offers
const result = await storage.createService({
username,
serviceFqn,
expiresAt,
isPublic: isPublic || false,
metadata: metadata ? JSON.stringify(metadata) : undefined,
offers: offerRequests
});
/**
* 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);
createdOffers = result.offers;
// Return full service details with all offers
return c.json({
topic,
offers: offers.map(o => ({
id: o.id,
peerId: o.peerId,
uuid: result.indexUuid,
serviceFqn: serviceFqn,
username: username,
serviceId: result.service.id,
offers: result.offers.map(o => ({
offerId: 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
})),
total: bloomParam ? total + excludePeerIds.length : total,
returned: offers.length
}, 200);
} catch (err) {
console.error('Error fetching offers by topic:', err);
return c.json({ error: 'Internal server error' }, 500);
}
});
/**
* GET /topics
* List all topics with active peer counts (paginated)
* Public endpoint (no auth required)
* Query params:
* - limit: Max topics to return (default 50, max 200)
* - offset: Number of topics to skip (default 0)
* - startsWith: Filter topics starting with this prefix (optional)
*/
app.get('/topics', async (c) => {
try {
const limitParam = c.req.query('limit');
const offsetParam = c.req.query('offset');
const startsWithParam = c.req.query('startsWith');
const limit = limitParam ? Math.min(parseInt(limitParam, 10), 200) : 50;
const offset = offsetParam ? parseInt(offsetParam, 10) : 0;
const startsWith = startsWithParam || undefined;
const result = await storage.getTopics(limit, offset, startsWith);
return c.json({
topics: result.topics,
total: result.total,
limit,
offset,
...(startsWith && { startsWith })
}, 200);
} catch (err) {
console.error('Error fetching topics:', err);
return c.json({ error: 'Internal server error' }, 500);
}
});
/**
* GET /peers/:peerId/offers
* View all offers from a specific peer
* Public endpoint
*/
app.get('/peers/:peerId/offers', async (c) => {
try {
const peerId = c.req.param('peerId');
const offers = await storage.getOffersByPeerId(peerId);
// Collect unique topics
const topicsSet = new Set<string>();
offers.forEach(o => o.topics.forEach(t => topicsSet.add(t)));
return c.json({
peerId,
offers: offers.map(o => ({
id: o.id,
sdp: o.sdp,
topics: o.topics,
expiresAt: o.expiresAt,
lastSeen: o.lastSeen,
hasSecret: !!o.secret // Indicate if secret is required without exposing it
})),
topics: Array.from(topicsSet)
}, 200);
} catch (err) {
console.error('Error fetching peer offers:', err);
return c.json({ error: 'Internal server error' }, 500);
}
});
/**
* GET /offers/mine
* List all offers owned by authenticated peer
* Requires authentication
*/
app.get('/offers/mine', authMiddleware, async (c) => {
try {
const peerId = getAuthenticatedPeerId(c);
const offers = await storage.getOffersByPeerId(peerId);
return c.json({
peerId,
offers: offers.map(o => ({
id: o.id,
sdp: o.sdp,
topics: o.topics,
createdAt: o.createdAt,
expiresAt: o.expiresAt,
lastSeen: o.lastSeen,
secret: o.secret, // Owner can see the secret
answererPeerId: o.answererPeerId,
answeredAt: o.answeredAt
}))
}, 200);
expiresAt: o.expiresAt
})),
isPublic: result.service.isPublic,
metadata: metadata,
createdAt: result.service.createdAt,
expiresAt: result.service.expiresAt
}, 201);
} catch (err) {
console.error('Error fetching own offers:', err);
return c.json({ error: 'Internal server error' }, 500);
console.error('Error creating service:', err);
console.error('Error details:', {
message: (err as Error).message,
stack: (err as Error).stack,
username: c.req.param('username'),
serviceFqn,
offerIds: createdOffers.map(o => o.id)
});
return c.json({
error: 'Internal server error',
details: (err as Error).message
}, 500);
}
});
/**
* DELETE /offers/:offerId
* Delete a specific offer
* Requires authentication and ownership
* DELETE /users/:username/services/:fqn
* Delete a service by username and FQN (RESTful)
*/
app.delete('/offers/:offerId', authMiddleware, async (c) => {
app.delete('/users/:username/services/:fqn', authMiddleware, async (c) => {
try {
const offerId = c.req.param('offerId');
const peerId = getAuthenticatedPeerId(c);
const username = c.req.param('username');
const serviceFqn = decodeURIComponent(c.req.param('fqn'));
const deleted = await storage.deleteOffer(offerId, peerId);
// Find service by username and FQN
const uuid = await storage.queryService(username, serviceFqn);
if (!uuid) {
return c.json({ error: 'Service not found' }, 404);
}
const service = await storage.getServiceByUuid(uuid);
if (!service) {
return c.json({ error: 'Service not found' }, 404);
}
const deleted = await storage.deleteService(service.id, username);
if (!deleted) {
return c.json({ error: 'Offer not found or not authorized' }, 404);
return c.json({ error: 'Service not found or not owned by this username' }, 404);
}
return c.json({ deleted: true }, 200);
return c.json({ success: true }, 200);
} catch (err) {
console.error('Error deleting offer:', err);
console.error('Error deleting service:', err);
return c.json({ error: 'Internal server error' }, 500);
}
});
// ===== Service Management (Legacy - for UUID-based access) =====
/**
* POST /offers/:offerId/answer
* Answer a specific offer (locks it to answerer)
* Requires authentication
* GET /services/:uuid
* Get service details by index UUID (kept for privacy)
*/
app.post('/offers/:offerId/answer', authMiddleware, async (c) => {
app.get('/services/:uuid', async (c) => {
try {
const offerId = c.req.param('offerId');
const peerId = getAuthenticatedPeerId(c);
const uuid = c.req.param('uuid');
const service = await storage.getServiceByUuid(uuid);
if (!service) {
return c.json({ error: 'Service not found' }, 404);
}
// Get all offers for this service
const serviceOffers = await storage.getOffersForService(service.id);
if (serviceOffers.length === 0) {
return c.json({ error: 'No offers found for this service' }, 404);
}
// Find an unanswered offer
const availableOffer = serviceOffers.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({
uuid: uuid,
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);
}
});
// ===== Service-Based WebRTC Signaling =====
/**
* POST /services/:uuid/answer
* Answer a service offer
*/
app.post('/services/:uuid/answer', authMiddleware, async (c) => {
try {
const uuid = c.req.param('uuid');
const body = await c.req.json();
const { sdp, secret } = body;
const { sdp } = body;
if (!sdp || typeof sdp !== 'string') {
return c.json({ error: 'Missing or invalid required parameter: sdp' }, 400);
if (!sdp) {
return c.json({ error: 'Missing required parameter: sdp' }, 400);
}
if (sdp.length > 65536) {
return c.json({ error: 'SDP must be 64KB or less' }, 400);
if (typeof sdp !== 'string' || sdp.length === 0) {
return c.json({ error: 'Invalid SDP' }, 400);
}
// Validate secret if provided
if (secret !== undefined && typeof secret !== 'string') {
return c.json({ error: 'Secret must be a string' }, 400);
if (sdp.length > 64 * 1024) {
return c.json({ error: 'SDP too large (max 64KB)' }, 400);
}
const result = await storage.answerOffer(offerId, peerId, sdp, secret);
// Get the service by UUID
const service = await storage.getServiceByUuid(uuid);
if (!service) {
return c.json({ error: 'Service not found' }, 404);
}
// Get available offer from service
const serviceOffers = await storage.getOffersForService(service.id);
const availableOffer = serviceOffers.find(offer => !offer.answererPeerId);
if (!availableOffer) {
return c.json({ error: 'No available offers' }, 503);
}
const answererPeerId = getAuthenticatedPeerId(c);
const result = await storage.answerOffer(availableOffer.id, answererPeerId, sdp);
if (!result.success) {
return c.json({ error: result.error }, 400);
}
return c.json({
offerId,
answererId: peerId,
answeredAt: Date.now()
success: true,
offerId: availableOffer.id
}, 200);
} catch (err) {
console.error('Error answering offer:', err);
console.error('Error answering service:', err);
return c.json({ error: 'Internal server error' }, 500);
}
});
/**
* GET /offers/answers
* Poll for answers to all of authenticated peer's offers
* Requires authentication (offerer)
* GET /services/:uuid/answer
* Get answer for a service (offerer polls this)
*/
app.get('/offers/answers', authMiddleware, async (c) => {
app.get('/services/:uuid/answer', authMiddleware, async (c) => {
try {
const uuid = c.req.param('uuid');
const peerId = getAuthenticatedPeerId(c);
const offers = await storage.getAnsweredOffers(peerId);
// Get the service by UUID
const service = await storage.getServiceByUuid(uuid);
if (!service) {
return c.json({ error: 'Service not found' }, 404);
}
// Get offers for this service owned by the requesting peer
const serviceOffers = await storage.getOffersForService(service.id);
const myOffer = serviceOffers.find(offer => offer.peerId === peerId && offer.answererPeerId);
if (!myOffer || !myOffer.answerSdp) {
return c.json({ error: 'Offer not yet answered' }, 404);
}
return c.json({
answers: offers.map(o => ({
offerId: o.id,
answererId: o.answererPeerId,
sdp: o.answerSdp,
answeredAt: o.answeredAt,
topics: o.topics
}))
offerId: myOffer.id,
answererId: myOffer.answererPeerId,
sdp: myOffer.answerSdp,
answeredAt: myOffer.answeredAt
}, 200);
} catch (err) {
console.error('Error fetching answers:', err);
console.error('Error getting service answer:', err);
return c.json({ error: 'Internal server error' }, 500);
}
});
/**
* POST /offers/:offerId/ice-candidates
* Post ICE candidates for an offer
* Requires authentication (must be offerer or answerer)
* POST /services/:uuid/ice-candidates
* Add ICE candidates for a service
*/
app.post('/offers/:offerId/ice-candidates', authMiddleware, async (c) => {
app.post('/services/:uuid/ice-candidates', authMiddleware, async (c) => {
try {
const offerId = c.req.param('offerId');
const peerId = getAuthenticatedPeerId(c);
const uuid = c.req.param('uuid');
const body = await c.req.json();
const { candidates } = body;
const { candidates, offerId } = body;
if (!Array.isArray(candidates) || candidates.length === 0) {
return c.json({ error: 'Missing or invalid required parameter: candidates (must be non-empty array)' }, 400);
return c.json({ error: 'Missing or invalid required parameter: candidates' }, 400);
}
// Verify offer exists and caller is offerer or answerer
const offer = await storage.getOfferById(offerId);
const peerId = getAuthenticatedPeerId(c);
// Get the service by UUID
const service = await storage.getServiceByUuid(uuid);
if (!service) {
return c.json({ error: 'Service not found' }, 404);
}
// If offerId is provided, use it; otherwise find the peer's offer
let targetOfferId = offerId;
if (!targetOfferId) {
const serviceOffers = await storage.getOffersForService(service.id);
const myOffer = serviceOffers.find(offer =>
offer.peerId === peerId || offer.answererPeerId === peerId
);
if (!myOffer) {
return c.json({ error: 'No offer found for this peer' }, 404);
}
targetOfferId = myOffer.id;
}
// Get offer to determine role
const offer = await storage.getOfferById(targetOfferId);
if (!offer) {
return c.json({ error: 'Offer not found or expired' }, 404);
return c.json({ error: 'Offer not found' }, 404);
}
let role: 'offerer' | 'answerer';
if (offer.peerId === peerId) {
role = 'offerer';
} else if (offer.answererPeerId === peerId) {
role = 'answerer';
} else {
return c.json({ error: 'Not authorized to post ICE candidates for this offer' }, 403);
}
// Determine role
const role = offer.peerId === peerId ? 'offerer' : 'answerer';
const added = await storage.addIceCandidates(offerId, peerId, role, candidates);
const count = await storage.addIceCandidates(targetOfferId, peerId, role, candidates);
return c.json({
offerId,
candidatesAdded: added
}, 200);
return c.json({ count, offerId: targetOfferId }, 200);
} catch (err) {
console.error('Error adding ICE candidates:', err);
console.error('Error adding ICE candidates to service:', err);
return c.json({ error: 'Internal server error' }, 500);
}
});
/**
* GET /offers/:offerId/ice-candidates
* Poll for ICE candidates from the other peer
* Requires authentication (must be offerer or answerer)
* GET /services/:uuid/ice-candidates
* Get ICE candidates for a service
*/
app.get('/offers/:offerId/ice-candidates', authMiddleware, async (c) => {
app.get('/services/:uuid/ice-candidates', authMiddleware, async (c) => {
try {
const offerId = c.req.param('offerId');
const uuid = c.req.param('uuid');
const since = c.req.query('since');
const offerId = c.req.query('offerId');
const peerId = getAuthenticatedPeerId(c);
const sinceParam = c.req.query('since');
const since = sinceParam ? parseInt(sinceParam, 10) : undefined;
// Get the service by UUID
const service = await storage.getServiceByUuid(uuid);
if (!service) {
return c.json({ error: 'Service not found' }, 404);
}
// Verify offer exists and caller is offerer or answerer
const offer = await storage.getOfferById(offerId);
// If offerId is provided, use it; otherwise find the peer's offer
let targetOfferId = offerId;
if (!targetOfferId) {
const serviceOffers = await storage.getOffersForService(service.id);
const myOffer = serviceOffers.find(offer =>
offer.peerId === peerId || offer.answererPeerId === peerId
);
if (!myOffer) {
return c.json({ error: 'No offer found for this peer' }, 404);
}
targetOfferId = myOffer.id;
}
// Get offer to determine role
const offer = await storage.getOfferById(targetOfferId);
if (!offer) {
return c.json({ error: 'Offer not found or expired' }, 404);
return c.json({ error: 'Offer not found' }, 404);
}
let targetRole: 'offerer' | 'answerer';
if (offer.peerId === peerId) {
// Offerer wants answerer's candidates
targetRole = 'answerer';
console.log(`[ICE GET] Offerer ${peerId} requesting answerer ICE candidates for offer ${offerId}, since=${since}, answererPeerId=${offer.answererPeerId}`);
} else if (offer.answererPeerId === peerId) {
// Answerer wants offerer's candidates
targetRole = 'offerer';
console.log(`[ICE GET] Answerer ${peerId} requesting offerer ICE candidates for offer ${offerId}, since=${since}, offererPeerId=${offer.peerId}`);
} else {
return c.json({ error: 'Not authorized to view ICE candidates for this offer' }, 403);
}
// Get candidates for opposite role
const targetRole = offer.peerId === peerId ? 'answerer' : 'offerer';
const sinceTimestamp = since ? parseInt(since, 10) : undefined;
const candidates = await storage.getIceCandidates(offerId, targetRole, since);
console.log(`[ICE GET] Found ${candidates.length} candidates for offer ${offerId}, targetRole=${targetRole}, since=${since}`);
const candidates = await storage.getIceCandidates(targetOfferId, targetRole, sinceTimestamp);
return c.json({
offerId,
candidates: candidates.map(c => ({
candidate: c.candidate,
peerId: c.peerId,
role: c.role,
createdAt: c.createdAt
}))
})),
offerId: targetOfferId
}, 200);
} catch (err) {
console.error('Error fetching ICE candidates:', err);
console.error('Error getting ICE candidates for service:', err);
return c.json({ error: 'Internal server error' }, 500);
}
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,7 +17,7 @@ OFFER_MIN_TTL = "60000" # Min offer TTL: 1 minute
MAX_OFFERS_PER_REQUEST = "100" # Max offers per request
MAX_TOPICS_PER_OFFER = "50" # Max topics per offer
CORS_ORIGINS = "*" # Comma-separated list of allowed origins
VERSION = "0.1.0" # Semantic version
VERSION = "0.4.0" # Semantic version
# AUTH_SECRET should be set as a secret, not a var
# Run: npx wrangler secret put AUTH_SECRET