diff --git a/README.md b/README.md index 968122c..55c12a0 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ [![npm version](https://img.shields.io/npm/v/@xtr-dev/rondevu-server)](https://www.npmjs.com/package/@xtr-dev/rondevu-server) -🌐 **Topic-based peer discovery and WebRTC signaling** +🌐 **DNS-like WebRTC signaling with username claiming and service discovery** -Scalable peer-to-peer connection establishment with topic-based discovery, stateless authentication, and complete WebRTC signaling. +Scalable WebRTC signaling server with cryptographic username claiming, service publishing, and privacy-preserving discovery. **Related repositories:** - [@xtr-dev/rondevu-client](https://github.com/xtr-dev/rondevu-client) - TypeScript client library ([npm](https://www.npmjs.com/package/@xtr-dev/rondevu-client)) @@ -15,14 +15,28 @@ Scalable peer-to-peer connection establishment with topic-based discovery, state ## Features -- **Topic-Based Discovery**: Tag offers with topics (e.g., torrent infohashes) for efficient peer finding +- **Username Claiming**: Cryptographic username ownership with Ed25519 signatures (365-day validity, auto-renewed on use) +- **Service Publishing**: Package-style naming with semantic versioning (com.example.chat@1.0.0) +- **Privacy-Preserving Discovery**: UUID-based service index prevents enumeration +- **Public/Private Services**: Control service visibility - **Stateless Authentication**: AES-256-GCM encrypted credentials, no server-side sessions -- **Protected Offers**: Optional secret field for access-controlled peer connections -- **Bloom Filters**: Client-side peer exclusion for efficient discovery -- **Multi-Offer Support**: Create multiple offers per peer simultaneously - **Complete WebRTC Signaling**: Offer/answer exchange and ICE candidate relay - **Dual Storage**: SQLite (Node.js/Docker) and Cloudflare D1 (Workers) backends +## Architecture + +``` +Username Claiming → Service Publishing → Service Discovery → WebRTC Connection + +alice claims "alice" with Ed25519 signature + ↓ +alice publishes com.example.chat@1.0.0 → receives UUID abc123 + ↓ +bob queries alice's services → gets UUID abc123 + ↓ +bob connects to UUID abc123 → WebRTC connection established +``` + ## Quick Start **Node.js:** @@ -32,7 +46,7 @@ npm install && npm start **Docker:** ```bash -docker build -t rondevu . && docker run -p 3000:3000 -e STORAGE_PATH=:memory: rondevu +docker build -t rondevu . && docker run -p 3000:3000 -e STORAGE_PATH=:memory: -e AUTH_SECRET=$(openssl rand -hex 32) rondevu ``` **Cloudflare Workers:** @@ -63,67 +77,172 @@ Generates a cryptographically random 128-bit peer ID. } ``` -#### `GET /topics?limit=50&offset=0` -List all topics with active peer counts (paginated) +### Username Management -**Query Parameters:** -- `limit` (optional): Maximum number of topics to return (default: 50, max: 200) -- `offset` (optional): Number of topics to skip (default: 0) +#### `POST /usernames/claim` +Claim a username with cryptographic proof -**Response:** +**Request:** ```json { - "topics": [ - {"topic": "movie-xyz", "activePeers": 42}, - {"topic": "torrent-abc", "activePeers": 15} - ], - "total": 123, - "limit": 50, - "offset": 0 + "username": "alice", + "publicKey": "base64-encoded-ed25519-public-key", + "signature": "base64-encoded-signature", + "message": "claim:alice:1733404800000" } ``` -#### `GET /offers/by-topic/:topic?limit=50&bloom=...` -Find offers by topic with optional bloom filter exclusion +**Response:** +```json +{ + "username": "alice", + "claimedAt": 1733404800000, + "expiresAt": 1765027200000 +} +``` -**Query Parameters:** -- `limit` (optional): Maximum offers to return (default: 50, max: 200) -- `bloom` (optional): Base64-encoded bloom filter to exclude known peers +**Validation:** +- Username format: `^[a-z0-9][a-z0-9-]*[a-z0-9]$` (3-32 characters) +- Signature must be valid Ed25519 signature +- Timestamp must be within 5 minutes (replay protection) +- Expires after 365 days, auto-renewed on use + +#### `GET /usernames/:username` +Check username availability and claim status **Response:** ```json { - "topic": "movie-xyz", - "offers": [ + "username": "alice", + "available": false, + "claimedAt": 1733404800000, + "expiresAt": 1765027200000, + "publicKey": "..." +} +``` + +#### `GET /usernames/:username/services` +List all services for a username (privacy-preserving) + +**Response:** +```json +{ + "username": "alice", + "services": [ { - "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 - "info": "Looking for peers in EU region" // Public info field (optional) + "uuid": "abc123", + "isPublic": false + }, + { + "uuid": "def456", + "isPublic": true, + "serviceFqn": "com.example.public@1.0.0", + "metadata": { "description": "Public service" } } - ], - "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. -- `info`: Optional public metadata field (max 128 characters) visible to all peers. +### Service Management -#### `GET /peers/:peerId/offers` -View all offers from a specific peer +#### `POST /services` +Publish a service (requires authentication and username signature) -### Authenticated Endpoints +**Headers:** +- `Authorization: Bearer {peerId}:{secret}` -All authenticated endpoints require `Authorization: Bearer {peerId}:{secret}` header. +**Request:** +```json +{ + "username": "alice", + "serviceFqn": "com.example.chat@1.0.0", + "sdp": "v=0...", + "ttl": 300000, + "isPublic": false, + "metadata": { "description": "Chat service" }, + "signature": "base64-encoded-signature", + "message": "publish:alice:com.example.chat@1.0.0:1733404800000" +} +``` + +**Response:** +```json +{ + "serviceId": "uuid-v4", + "uuid": "uuid-v4-for-index", + "offerId": "offer-hash-id", + "expiresAt": 1733405100000 +} +``` + +**Service FQN Format:** +- Service name: Reverse domain notation (e.g., `com.example.chat`) +- Version: Semantic versioning (e.g., `1.0.0`, `2.1.3-beta`) +- Complete FQN: `service-name@version` (e.g., `com.example.chat@1.0.0`) + +**Validation:** +- Service name pattern: `^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$` +- Length: 3-128 characters +- Version pattern: `^[0-9]+\.[0-9]+\.[0-9]+(-[a-z0-9.-]+)?$` + +#### `GET /services/:uuid` +Get service details by UUID + +**Response:** +```json +{ + "serviceId": "...", + "username": "alice", + "serviceFqn": "com.example.chat@1.0.0", + "offerId": "...", + "sdp": "v=0...", + "isPublic": false, + "metadata": { ... }, + "createdAt": 1733404800000, + "expiresAt": 1733405100000 +} +``` + +#### `DELETE /services/:serviceId` +Unpublish a service (requires authentication and ownership) + +**Headers:** +- `Authorization: Bearer {peerId}:{secret}` + +**Request:** +```json +{ + "username": "alice" +} +``` + +### Service Discovery + +#### `POST /index/:username/query` +Query a service by FQN + +**Request:** +```json +{ + "serviceFqn": "com.example.chat@1.0.0" +} +``` + +**Response:** +```json +{ + "uuid": "abc123", + "allowed": true +} +``` + +### Offer Management (Low-level) #### `POST /offers` -Create one or more offers +Create one or more offers (requires authentication) + +**Headers:** +- `Authorization: Bearer {peerId}:{secret}` **Request:** ```json @@ -131,19 +250,12 @@ Create one or more offers "offers": [ { "sdp": "v=0...", - "topics": ["movie-xyz", "hd-content"], - "ttl": 300000, - "secret": "my-secret-password", // Optional: protect offer (max 128 chars) - "info": "Looking for peers in EU region" // Optional: public info (max 128 chars) + "ttl": 300000 } ] } ``` -**Notes:** -- `secret` (optional): Protect the offer with a secret. Answerers must provide the correct secret to connect. -- `info` (optional): Public metadata visible to all peers (max 128 characters). Useful for describing the offer or connection requirements. - #### `GET /offers/mine` List all offers owned by authenticated peer @@ -159,14 +271,10 @@ Answer an offer (locks it to answerer) **Request:** ```json { - "sdp": "v=0...", - "secret": "my-secret-password" // Required if offer is protected + "sdp": "v=0..." } ``` -**Notes:** -- `secret` (optional): Required if the offer was created with a secret. Must match the offer's secret. - #### `GET /offers/answers` Poll for answers to your offers @@ -192,13 +300,62 @@ Environment variables: | `PORT` | `3000` | Server port (Node.js/Docker) | | `CORS_ORIGINS` | `*` | Comma-separated allowed origins | | `STORAGE_PATH` | `./rondevu.db` | SQLite database path (use `:memory:` for in-memory) | -| `VERSION` | `0.4.0` | Server version (semver) | -| `AUTH_SECRET` | Random 32-byte hex | Secret key for credential encryption | +| `VERSION` | `2.0.0` | Server version (semver) | +| `AUTH_SECRET` | Random 32-byte hex | Secret key for credential encryption (required for production) | | `OFFER_DEFAULT_TTL` | `300000` | Default offer TTL in ms (5 minutes) | | `OFFER_MIN_TTL` | `60000` | Minimum offer TTL in ms (1 minute) | | `OFFER_MAX_TTL` | `3600000` | Maximum offer TTL in ms (1 hour) | | `MAX_OFFERS_PER_REQUEST` | `10` | Maximum offers per create request | -| `MAX_TOPICS_PER_OFFER` | `20` | Maximum topics per offer | + +## Database Schema + +### usernames +- `username` (PK): Claimed username +- `public_key`: Ed25519 public key (base64) +- `claimed_at`: Claim timestamp +- `expires_at`: Expiry timestamp (365 days) +- `last_used`: Last activity timestamp +- `metadata`: Optional JSON metadata + +### services +- `id` (PK): Service ID (UUID) +- `username` (FK): Owner username +- `service_fqn`: Fully qualified name (com.example.chat@1.0.0) +- `offer_id` (FK): WebRTC offer ID +- `is_public`: Public/private flag +- `metadata`: JSON metadata +- `created_at`, `expires_at`: Timestamps + +### service_index (privacy layer) +- `uuid` (PK): Random UUID for discovery +- `service_id` (FK): Links to service +- `username`, `service_fqn`: Denormalized for performance + +## Security + +### Username Claiming +- **Algorithm**: Ed25519 signatures +- **Message Format**: `claim:{username}:{timestamp}` +- **Replay Protection**: Timestamp must be within 5 minutes +- **Key Management**: Private keys never leave the client + +### Service Publishing +- **Ownership Verification**: Every publish requires username signature +- **Message Format**: `publish:{username}:{serviceFqn}:{timestamp}` +- **Auto-Renewal**: Publishing a service extends username expiry + +### Privacy +- **Private Services**: Only UUID exposed, FQN hidden +- **Public Services**: FQN and metadata visible +- **No Enumeration**: Cannot list all services without knowing FQN + +## Migration from V1 + +V2 is a **breaking change** that removes topic-based discovery. See [MIGRATION.md](../MIGRATION.md) for detailed migration guide. + +**Key Changes:** +- ❌ Removed: Topic-based discovery, bloom filters, public peer listings +- ✅ Added: Username claiming, service publishing, UUID-based privacy ## License diff --git a/package-lock.json b/package-lock.json index 9f87478..8755654 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "@xtr-dev/rondevu-server", - "version": "0.1.4", + "version": "0.1.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@xtr-dev/rondevu-server", - "version": "0.1.4", + "version": "0.1.5", "dependencies": { "@hono/node-server": "^1.19.6", + "@noble/ed25519": "^3.0.0", "better-sqlite3": "^12.4.1", "hono": "^4.10.4" }, @@ -523,6 +524,15 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@noble/ed25519": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-3.0.0.tgz", + "integrity": "sha512-QyteqMNm0GLqfa5SoYbSC3+Pvykwpn95Zgth4MFVSMKBB75ELl9tX1LAVsN4c3HXOrakHsF2gL4zWDAYCcsnzg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@tsconfig/node10": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", diff --git a/package.json b/package.json index 39b701b..147191b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@xtr-dev/rondevu-server", - "version": "0.1.5", - "description": "Topic-based peer discovery and signaling server for distributed P2P applications", + "version": "2.0.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" } diff --git a/src/app.ts b/src/app.ts index 30709b4..3239319 100644 --- a/src/app.ts +++ b/src/app.ts @@ -3,12 +3,11 @@ import { cors } from 'hono/cors'; import { Storage } from './storage/types.ts'; import { Config } from './config.ts'; import { createAuthMiddleware, getAuthenticatedPeerId } from './middleware/auth.ts'; -import { generatePeerId, encryptPeerId } from './crypto.ts'; -import { parseBloomFilter } from './bloom.ts'; +import { generatePeerId, encryptPeerId, validateUsernameClaim, validateServiceFqn } from './crypto.ts'; import type { Context } from 'hono'; /** - * Creates the Hono application with topic-based WebRTC signaling endpoints + * Creates the Hono application with username and service-based WebRTC signaling */ export function createApp(storage: Storage, config: Config) { const app = new Hono(); @@ -16,18 +15,15 @@ export function createApp(storage: Storage, config: Config) { // Create auth middleware const authMiddleware = createAuthMiddleware(config.authSecret); - // Enable CORS with dynamic origin handling + // Enable CORS app.use('/*', cors({ origin: (origin) => { - // If no origin restrictions (wildcard), allow any origin if (config.corsOrigins.length === 1 && config.corsOrigins[0] === '*') { return origin; } - // Otherwise check if origin is in allowed list if (config.corsOrigins.includes(origin)) { return origin; } - // Default to first allowed origin return config.corsOrigins[0]; }, allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], @@ -37,21 +33,23 @@ export function createApp(storage: Storage, config: Config) { credentials: true, })); + // ===== General Endpoints ===== + /** * GET / - * Returns server version information + * Returns server information */ app.get('/', (c) => { return c.json({ version: config.version, name: 'Rondevu', - description: 'Topic-based peer discovery and signaling server' + description: 'DNS-like WebRTC signaling with username claiming and service discovery' }); }); /** * GET /health - * Health check endpoint with version + * Health check endpoint */ app.get('/health', (c) => { return c.json({ @@ -63,15 +61,11 @@ export function createApp(storage: Storage, config: Config) { /** * POST /register - * Register a new peer and receive credentials - * Generates a cryptographically random peer ID (128-bit) + * Register a new peer (still needed for peer ID generation) */ app.post('/register', async (c) => { try { - // Always generate a random peer ID const peerId = generatePeerId(); - - // Encrypt peer ID with server secret (async operation) const secret = await encryptPeerId(peerId, config.authSecret); return c.json({ @@ -84,10 +78,292 @@ export function createApp(storage: Storage, config: Config) { } }); + // ===== Username Management ===== + + /** + * POST /usernames/claim + * Claim a username with cryptographic proof + */ + app.post('/usernames/claim', async (c) => { + try { + const body = await c.req.json(); + const { username, publicKey, signature, message } = body; + + if (!username || !publicKey || !signature || !message) { + return c.json({ error: 'Missing required parameters: username, publicKey, signature, message' }, 400); + } + + // Validate claim + const validation = await validateUsernameClaim(username, publicKey, signature, message); + if (!validation.valid) { + return c.json({ error: validation.error }, 400); + } + + // Attempt to claim username + try { + const claimed = await storage.claimUsername({ + username, + publicKey, + signature, + message + }); + + return c.json({ + username: claimed.username, + claimedAt: claimed.claimedAt, + expiresAt: claimed.expiresAt + }, 200); + } catch (err: any) { + if (err.message?.includes('already claimed')) { + return c.json({ error: 'Username already claimed by different public key' }, 409); + } + throw err; + } + } catch (err) { + console.error('Error claiming username:', err); + return c.json({ error: 'Internal server error' }, 500); + } + }); + + /** + * GET /usernames/:username + * Check if username is available or get claim info + */ + app.get('/usernames/:username', async (c) => { + try { + const username = c.req.param('username'); + + const claimed = await storage.getUsername(username); + + if (!claimed) { + return c.json({ + username, + available: true + }, 200); + } + + return c.json({ + username: claimed.username, + available: false, + claimedAt: claimed.claimedAt, + expiresAt: claimed.expiresAt, + publicKey: claimed.publicKey + }, 200); + } catch (err) { + console.error('Error checking username:', err); + return c.json({ error: 'Internal server error' }, 500); + } + }); + + /** + * GET /usernames/:username/services + * List services for a username (privacy-preserving) + */ + app.get('/usernames/:username/services', async (c) => { + try { + const username = c.req.param('username'); + + const services = await storage.listServicesForUsername(username); + + return c.json({ + username, + services + }, 200); + } catch (err) { + console.error('Error listing services:', err); + return c.json({ error: 'Internal server error' }, 500); + } + }); + + // ===== Service Management ===== + + /** + * POST /services + * Publish a service + */ + app.post('/services', authMiddleware, async (c) => { + try { + const body = await c.req.json(); + const { username, serviceFqn, sdp, ttl, isPublic, metadata, signature, message } = body; + + if (!username || !serviceFqn || !sdp) { + return c.json({ error: 'Missing required parameters: username, serviceFqn, sdp' }, 400); + } + + // Validate service FQN + const fqnValidation = validateServiceFqn(serviceFqn); + if (!fqnValidation.valid) { + return c.json({ error: fqnValidation.error }, 400); + } + + // Verify username ownership (signature required) + if (!signature || !message) { + return c.json({ error: 'Missing signature or message for username verification' }, 400); + } + + const usernameRecord = await storage.getUsername(username); + if (!usernameRecord) { + return c.json({ error: 'Username not claimed' }, 404); + } + + // Verify signature matches username's public key + const signatureValidation = await validateUsernameClaim(username, usernameRecord.publicKey, signature, message); + if (!signatureValidation.valid) { + return c.json({ error: 'Invalid signature for username' }, 403); + } + + // Validate SDP + if (typeof sdp !== 'string' || sdp.length === 0) { + return c.json({ error: 'Invalid SDP' }, 400); + } + + if (sdp.length > 64 * 1024) { + return c.json({ error: 'SDP too large (max 64KB)' }, 400); + } + + // Calculate expiry + const peerId = getAuthenticatedPeerId(c); + const offerTtl = Math.min( + Math.max(ttl || config.offerDefaultTtl, config.offerMinTtl), + config.offerMaxTtl + ); + const expiresAt = Date.now() + offerTtl; + + // Create offer first + const offers = await storage.createOffers([{ + peerId, + sdp, + expiresAt + }]); + + if (offers.length === 0) { + return c.json({ error: 'Failed to create offer' }, 500); + } + + const offer = offers[0]; + + // Create service + const result = await storage.createService({ + username, + serviceFqn, + offerId: offer.id, + expiresAt, + isPublic: isPublic || false, + metadata: metadata ? JSON.stringify(metadata) : undefined + }); + + return c.json({ + serviceId: result.service.id, + uuid: result.indexUuid, + offerId: offer.id, + expiresAt: result.service.expiresAt + }, 201); + } catch (err) { + console.error('Error creating service:', err); + return c.json({ error: 'Internal server error' }, 500); + } + }); + + /** + * GET /services/:uuid + * Get service details by index UUID + */ + app.get('/services/:uuid', async (c) => { + try { + const uuid = c.req.param('uuid'); + + const service = await storage.getServiceByUuid(uuid); + + if (!service) { + return c.json({ error: 'Service not found' }, 404); + } + + // Get associated offer + const offer = await storage.getOfferById(service.offerId); + + if (!offer) { + return c.json({ error: 'Associated offer not found' }, 404); + } + + return c.json({ + serviceId: service.id, + username: service.username, + serviceFqn: service.serviceFqn, + offerId: service.offerId, + sdp: offer.sdp, + isPublic: service.isPublic, + metadata: service.metadata ? JSON.parse(service.metadata) : undefined, + createdAt: service.createdAt, + expiresAt: service.expiresAt + }, 200); + } catch (err) { + console.error('Error getting service:', err); + return c.json({ error: 'Internal server error' }, 500); + } + }); + + /** + * DELETE /services/:serviceId + * Delete a service (requires ownership) + */ + app.delete('/services/:serviceId', authMiddleware, async (c) => { + try { + const serviceId = c.req.param('serviceId'); + const body = await c.req.json(); + const { username } = body; + + if (!username) { + return c.json({ error: 'Missing required parameter: username' }, 400); + } + + const deleted = await storage.deleteService(serviceId, username); + + if (!deleted) { + return c.json({ error: 'Service not found or not owned by this username' }, 404); + } + + return c.json({ success: true }, 200); + } catch (err) { + console.error('Error deleting service:', err); + return c.json({ error: 'Internal server error' }, 500); + } + }); + + /** + * POST /index/:username/query + * Query service by FQN (returns UUID) + */ + app.post('/index/:username/query', async (c) => { + try { + const username = c.req.param('username'); + const body = await c.req.json(); + const { serviceFqn } = body; + + if (!serviceFqn) { + return c.json({ error: 'Missing required parameter: serviceFqn' }, 400); + } + + const uuid = await storage.queryService(username, serviceFqn); + + if (!uuid) { + return c.json({ error: 'Service not found' }, 404); + } + + return c.json({ + uuid, + allowed: true + }, 200); + } catch (err) { + console.error('Error querying service:', err); + return c.json({ error: 'Internal server error' }, 500); + } + }); + + // ===== Offer Management (Core WebRTC) ===== + /** * POST /offers - * Creates one or more offers with topics - * Requires authentication + * Create offers (direct, no service - for testing/advanced users) */ app.post('/offers', authMiddleware, async (c) => { try { @@ -99,230 +375,56 @@ export function createApp(storage: Storage, config: Config) { } if (offers.length > config.maxOffersPerRequest) { - return c.json({ error: `Too many offers. Maximum ${config.maxOffersPerRequest} per request` }, 400); + return c.json({ error: `Too many offers (max ${config.maxOffersPerRequest})` }, 400); } const peerId = getAuthenticatedPeerId(c); // Validate and prepare offers - const offerRequests = []; - for (const offer of offers) { - // Validate SDP - if (!offer.sdp || typeof offer.sdp !== 'string') { - return c.json({ error: 'Each offer must have an sdp field' }, 400); + const validated = offers.map((offer: any) => { + const { sdp, ttl, secret } = offer; + + if (typeof sdp !== 'string' || sdp.length === 0) { + throw new Error('Invalid SDP in offer'); } - if (offer.sdp.length > 65536) { - return c.json({ error: 'SDP must be 64KB or less' }, 400); + if (sdp.length > 64 * 1024) { + throw new Error('SDP too large (max 64KB)'); } - // Validate secret if provided - if (offer.secret !== undefined) { - if (typeof offer.secret !== 'string') { - return c.json({ error: 'Secret must be a string' }, 400); - } - if (offer.secret.length > 128) { - return c.json({ error: 'Secret must be 128 characters or less' }, 400); - } - } + const offerTtl = Math.min( + Math.max(ttl || config.offerDefaultTtl, config.offerMinTtl), + config.offerMaxTtl + ); - // Validate info if provided - if (offer.info !== undefined) { - if (typeof offer.info !== 'string') { - return c.json({ error: 'Info must be a string' }, 400); - } - if (offer.info.length > 128) { - return c.json({ error: 'Info must be 128 characters or less' }, 400); - } - } - - // Validate topics - if (!Array.isArray(offer.topics) || offer.topics.length === 0) { - return c.json({ error: 'Each offer must have a non-empty topics array' }, 400); - } - - if (offer.topics.length > config.maxTopicsPerOffer) { - return c.json({ error: `Too many topics. Maximum ${config.maxTopicsPerOffer} per offer` }, 400); - } - - for (const topic of offer.topics) { - if (typeof topic !== 'string' || topic.length === 0 || topic.length > 256) { - return c.json({ error: 'Each topic must be a string between 1 and 256 characters' }, 400); - } - } - - // Validate and clamp TTL - let ttl = offer.ttl || config.offerDefaultTtl; - if (ttl < config.offerMinTtl) { - ttl = config.offerMinTtl; - } - if (ttl > config.offerMaxTtl) { - ttl = config.offerMaxTtl; - } - - offerRequests.push({ - id: offer.id, + return { peerId, - sdp: offer.sdp, - topics: offer.topics, - expiresAt: Date.now() + ttl, - secret: offer.secret, - info: offer.info, - }); - } + sdp, + expiresAt: Date.now() + offerTtl, + secret: secret ? String(secret).substring(0, 128) : undefined + }; + }); - // Create offers - const createdOffers = await storage.createOffers(offerRequests); + const created = await storage.createOffers(validated); - // Return simplified response return c.json({ - offers: createdOffers.map(o => ({ - id: o.id, - peerId: o.peerId, - topics: o.topics, - expiresAt: o.expiresAt + offers: created.map(offer => ({ + id: offer.id, + peerId: offer.peerId, + expiresAt: offer.expiresAt, + createdAt: offer.createdAt, + hasSecret: !!offer.secret })) - }, 200); - } catch (err) { + }, 201); + } catch (err: any) { console.error('Error creating offers:', err); - return c.json({ error: 'Internal server error' }, 500); - } - }); - - /** - * GET /offers/by-topic/:topic - * Find offers by topic with optional bloom filter exclusion - * Public endpoint (no auth required) - */ - app.get('/offers/by-topic/:topic', async (c) => { - try { - const topic = c.req.param('topic'); - const bloomParam = c.req.query('bloom'); - const limitParam = c.req.query('limit'); - - const limit = limitParam ? Math.min(parseInt(limitParam, 10), 200) : 50; - - // Parse bloom filter if provided - let excludePeerIds: string[] = []; - if (bloomParam) { - const bloom = parseBloomFilter(bloomParam); - if (!bloom) { - return c.json({ error: 'Invalid bloom filter format' }, 400); - } - - // Get all offers for topic first - const allOffers = await storage.getOffersByTopic(topic); - - // Test each peer ID against bloom filter - const excludeSet = new Set(); - for (const offer of allOffers) { - if (bloom.test(offer.peerId)) { - excludeSet.add(offer.peerId); - } - } - - excludePeerIds = Array.from(excludeSet); - } - - // Get filtered offers - let offers = await storage.getOffersByTopic(topic, excludePeerIds.length > 0 ? excludePeerIds : undefined); - - // Apply limit - const total = offers.length; - offers = offers.slice(0, limit); - - return c.json({ - topic, - offers: offers.map(o => ({ - id: o.id, - peerId: o.peerId, - sdp: o.sdp, - topics: o.topics, - expiresAt: o.expiresAt, - lastSeen: o.lastSeen, - hasSecret: !!o.secret, // Indicate if secret is required without exposing it - info: o.info // Public info field - })), - total: bloomParam ? total + excludePeerIds.length : total, - returned: offers.length - }, 200); - } catch (err) { - console.error('Error fetching offers by topic:', err); - return c.json({ error: 'Internal server error' }, 500); - } - }); - - /** - * GET /topics - * List all topics with active peer counts (paginated) - * Public endpoint (no auth required) - * Query params: - * - limit: Max topics to return (default 50, max 200) - * - offset: Number of topics to skip (default 0) - * - startsWith: Filter topics starting with this prefix (optional) - */ - app.get('/topics', async (c) => { - try { - const limitParam = c.req.query('limit'); - const offsetParam = c.req.query('offset'); - const startsWithParam = c.req.query('startsWith'); - - const limit = limitParam ? Math.min(parseInt(limitParam, 10), 200) : 50; - const offset = offsetParam ? parseInt(offsetParam, 10) : 0; - const startsWith = startsWithParam || undefined; - - const result = await storage.getTopics(limit, offset, startsWith); - - return c.json({ - topics: result.topics, - total: result.total, - limit, - offset, - ...(startsWith && { startsWith }) - }, 200); - } catch (err) { - console.error('Error fetching topics:', err); - return c.json({ error: 'Internal server error' }, 500); - } - }); - - /** - * GET /peers/:peerId/offers - * View all offers from a specific peer - * Public endpoint - */ - app.get('/peers/:peerId/offers', async (c) => { - try { - const peerId = c.req.param('peerId'); - const offers = await storage.getOffersByPeerId(peerId); - - // Collect unique topics - const topicsSet = new Set(); - offers.forEach(o => o.topics.forEach(t => topicsSet.add(t))); - - return c.json({ - peerId, - offers: offers.map(o => ({ - id: o.id, - sdp: o.sdp, - topics: o.topics, - expiresAt: o.expiresAt, - lastSeen: o.lastSeen, - hasSecret: !!o.secret, // Indicate if secret is required without exposing it - info: o.info // Public info field - })), - topics: Array.from(topicsSet) - }, 200); - } catch (err) { - console.error('Error fetching peer offers:', err); - return c.json({ error: 'Internal server error' }, 500); + return c.json({ error: err.message || 'Internal server error' }, 500); } }); /** * GET /offers/mine - * List all offers owned by authenticated peer - * Requires authentication + * Get authenticated peer's offers */ app.get('/offers/mine', authMiddleware, async (c) => { try { @@ -330,30 +432,26 @@ export function createApp(storage: Storage, config: Config) { const offers = await storage.getOffersByPeerId(peerId); return c.json({ - peerId, - offers: offers.map(o => ({ - id: o.id, - sdp: o.sdp, - topics: o.topics, - createdAt: o.createdAt, - expiresAt: o.expiresAt, - lastSeen: o.lastSeen, - secret: o.secret, // Owner can see the secret - info: o.info, // Owner can see the info - answererPeerId: o.answererPeerId, - answeredAt: o.answeredAt + offers: offers.map(offer => ({ + id: offer.id, + sdp: offer.sdp, + createdAt: offer.createdAt, + expiresAt: offer.expiresAt, + lastSeen: offer.lastSeen, + hasSecret: !!offer.secret, + answererPeerId: offer.answererPeerId, + answered: !!offer.answererPeerId })) }, 200); } catch (err) { - console.error('Error fetching own offers:', err); + console.error('Error getting offers:', err); return c.json({ error: 'Internal server error' }, 500); } }); /** * DELETE /offers/:offerId - * Delete a specific offer - * Requires authentication and ownership + * Delete an offer */ app.delete('/offers/:offerId', authMiddleware, async (c) => { try { @@ -363,10 +461,10 @@ export function createApp(storage: Storage, config: Config) { const deleted = await storage.deleteOffer(offerId, peerId); if (!deleted) { - return c.json({ error: 'Offer not found or not authorized' }, 404); + return c.json({ error: 'Offer not found or not owned by this peer' }, 404); } - return c.json({ deleted: true }, 200); + return c.json({ success: true }, 200); } catch (err) { console.error('Error deleting offer:', err); return c.json({ error: 'Internal server error' }, 500); @@ -375,40 +473,35 @@ export function createApp(storage: Storage, config: Config) { /** * POST /offers/:offerId/answer - * Answer a specific offer (locks it to answerer) - * Requires authentication + * Answer an offer */ app.post('/offers/:offerId/answer', authMiddleware, async (c) => { try { const offerId = c.req.param('offerId'); - const peerId = getAuthenticatedPeerId(c); const body = await c.req.json(); const { sdp, secret } = body; - if (!sdp || typeof sdp !== 'string') { - return c.json({ error: 'Missing or invalid required parameter: sdp' }, 400); + if (!sdp) { + return c.json({ error: 'Missing required parameter: sdp' }, 400); } - if (sdp.length > 65536) { - return c.json({ error: 'SDP must be 64KB or less' }, 400); + if (typeof sdp !== 'string' || sdp.length === 0) { + return c.json({ error: 'Invalid SDP' }, 400); } - // Validate secret if provided - if (secret !== undefined && typeof secret !== 'string') { - return c.json({ error: 'Secret must be a string' }, 400); + if (sdp.length > 64 * 1024) { + return c.json({ error: 'SDP too large (max 64KB)' }, 400); } - const result = await storage.answerOffer(offerId, peerId, sdp, secret); + const answererPeerId = getAuthenticatedPeerId(c); + + const result = await storage.answerOffer(offerId, answererPeerId, sdp, secret); if (!result.success) { return c.json({ error: result.error }, 400); } - return c.json({ - offerId, - answererId: peerId, - answeredAt: Date.now() - }, 200); + return c.json({ success: true }, 200); } catch (err) { console.error('Error answering offer:', err); return c.json({ error: 'Internal server error' }, 500); @@ -417,8 +510,7 @@ export function createApp(storage: Storage, config: Config) { /** * GET /offers/answers - * Poll for answers to all of authenticated peer's offers - * Requires authentication (offerer) + * Get answers for authenticated peer's offers */ app.get('/offers/answers', authMiddleware, async (c) => { try { @@ -426,57 +518,49 @@ export function createApp(storage: Storage, config: Config) { const offers = await storage.getAnsweredOffers(peerId); return c.json({ - answers: offers.map(o => ({ - offerId: o.id, - answererId: o.answererPeerId, - sdp: o.answerSdp, - answeredAt: o.answeredAt, - topics: o.topics + answers: offers.map(offer => ({ + offerId: offer.id, + answererPeerId: offer.answererPeerId, + answerSdp: offer.answerSdp, + answeredAt: offer.answeredAt })) }, 200); } catch (err) { - console.error('Error fetching answers:', err); + console.error('Error getting answers:', err); return c.json({ error: 'Internal server error' }, 500); } }); + // ===== ICE Candidate Exchange ===== + /** * POST /offers/:offerId/ice-candidates - * Post ICE candidates for an offer - * Requires authentication (must be offerer or answerer) + * Add ICE candidates for an offer */ app.post('/offers/:offerId/ice-candidates', authMiddleware, async (c) => { try { const offerId = c.req.param('offerId'); - const peerId = getAuthenticatedPeerId(c); const body = await c.req.json(); const { candidates } = body; if (!Array.isArray(candidates) || candidates.length === 0) { - return c.json({ error: 'Missing or invalid required parameter: candidates (must be non-empty array)' }, 400); + return c.json({ error: 'Missing or invalid required parameter: candidates' }, 400); } - // Verify offer exists and caller is offerer or answerer + const peerId = getAuthenticatedPeerId(c); + + // Get offer to determine role const offer = await storage.getOfferById(offerId); if (!offer) { - return c.json({ error: 'Offer not found or expired' }, 404); + return c.json({ error: 'Offer not found' }, 404); } - let role: 'offerer' | 'answerer'; - if (offer.peerId === peerId) { - role = 'offerer'; - } else if (offer.answererPeerId === peerId) { - role = 'answerer'; - } else { - return c.json({ error: 'Not authorized to post ICE candidates for this offer' }, 403); - } + // Determine role + const role = offer.peerId === peerId ? 'offerer' : 'answerer'; - const added = await storage.addIceCandidates(offerId, peerId, role, candidates); + const count = await storage.addIceCandidates(offerId, peerId, role, candidates); - return c.json({ - offerId, - candidatesAdded: added - }, 200); + return c.json({ count }, 200); } catch (err) { console.error('Error adding ICE candidates:', err); return c.json({ error: 'Internal server error' }, 500); @@ -485,50 +569,34 @@ export function createApp(storage: Storage, config: Config) { /** * GET /offers/:offerId/ice-candidates - * Poll for ICE candidates from the other peer - * Requires authentication (must be offerer or answerer) + * Get ICE candidates for an offer */ app.get('/offers/:offerId/ice-candidates', authMiddleware, async (c) => { try { const offerId = c.req.param('offerId'); + const since = c.req.query('since'); const peerId = getAuthenticatedPeerId(c); - const sinceParam = c.req.query('since'); - const since = sinceParam ? parseInt(sinceParam, 10) : undefined; - - // Verify offer exists and caller is offerer or answerer + // Get offer to determine role const offer = await storage.getOfferById(offerId); if (!offer) { - return c.json({ error: 'Offer not found or expired' }, 404); + return c.json({ error: 'Offer not found' }, 404); } - let targetRole: 'offerer' | 'answerer'; - if (offer.peerId === peerId) { - // Offerer wants answerer's candidates - targetRole = 'answerer'; - console.log(`[ICE GET] Offerer ${peerId} requesting answerer ICE candidates for offer ${offerId}, since=${since}, answererPeerId=${offer.answererPeerId}`); - } else if (offer.answererPeerId === peerId) { - // Answerer wants offerer's candidates - targetRole = 'offerer'; - console.log(`[ICE GET] Answerer ${peerId} requesting offerer ICE candidates for offer ${offerId}, since=${since}, offererPeerId=${offer.peerId}`); - } else { - return c.json({ error: 'Not authorized to view ICE candidates for this offer' }, 403); - } + // Get candidates for opposite role + const targetRole = offer.peerId === peerId ? 'answerer' : 'offerer'; + const sinceTimestamp = since ? parseInt(since, 10) : undefined; - const candidates = await storage.getIceCandidates(offerId, targetRole, since); - console.log(`[ICE GET] Found ${candidates.length} candidates for offer ${offerId}, targetRole=${targetRole}, since=${since}`); + const candidates = await storage.getIceCandidates(offerId, targetRole, sinceTimestamp); return c.json({ - offerId, candidates: candidates.map(c => ({ candidate: c.candidate, - peerId: c.peerId, - role: c.role, createdAt: c.createdAt })) }, 200); } catch (err) { - console.error('Error fetching ICE candidates:', err); + console.error('Error getting ICE candidates:', err); return c.json({ error: 'Internal server error' }, 500); } }); diff --git a/src/crypto.ts b/src/crypto.ts index dc4afe0..e14f874 100644 --- a/src/crypto.ts +++ b/src/crypto.ts @@ -1,12 +1,23 @@ /** * 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'; + 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 +158,156 @@ export async function validateCredentials(peerId: string, encryptedSecret: strin return false; } } + +// ===== Username and Ed25519 Signature Utilities ===== + +/** + * Validates username format + * Rules: 3-32 chars, lowercase alphanumeric + dash, must start/end with alphanumeric + */ +export function validateUsername(username: string): { valid: boolean; error?: string } { + if (typeof username !== 'string') { + return { valid: false, error: 'Username must be a string' }; + } + + if (username.length < USERNAME_MIN_LENGTH) { + return { valid: false, error: `Username must be at least ${USERNAME_MIN_LENGTH} characters` }; + } + + if (username.length > USERNAME_MAX_LENGTH) { + return { valid: false, error: `Username must be at most ${USERNAME_MAX_LENGTH} characters` }; + } + + if (!USERNAME_REGEX.test(username)) { + return { valid: false, error: 'Username must be lowercase alphanumeric with optional dashes, and start/end with alphanumeric' }; + } + + return { valid: true }; +} + +/** + * Validates service FQN format (service-name@version) + * Service name: reverse domain notation (com.example.service) + * Version: semantic versioning (1.0.0, 2.1.3-beta, etc.) + */ +export function validateServiceFqn(fqn: string): { valid: boolean; error?: string } { + if (typeof fqn !== 'string') { + return { valid: false, error: 'Service FQN must be a string' }; + } + + // Split into service name and version + const parts = fqn.split('@'); + if (parts.length !== 2) { + return { valid: false, error: 'Service FQN must be in format: service-name@version' }; + } + + const [serviceName, version] = parts; + + // Validate service name (reverse domain notation) + const serviceNameRegex = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/; + if (!serviceNameRegex.test(serviceName)) { + return { valid: false, error: 'Service name must be reverse domain notation (e.g., com.example.service)' }; + } + + if (serviceName.length < 3 || serviceName.length > 128) { + return { valid: false, error: 'Service name must be 3-128 characters' }; + } + + // Validate version (semantic versioning) + const versionRegex = /^[0-9]+\.[0-9]+\.[0-9]+(-[a-z0-9.-]+)?$/; + if (!versionRegex.test(version)) { + return { valid: false, error: 'Version must be semantic versioning (e.g., 1.0.0, 2.1.3-beta)' }; + } + + return { valid: true }; +} + +/** + * Validates timestamp is within acceptable range (prevents replay attacks) + */ +export function validateTimestamp(timestamp: number): { valid: boolean; error?: string } { + if (typeof timestamp !== 'number' || !Number.isFinite(timestamp)) { + return { valid: false, error: 'Timestamp must be a finite number' }; + } + + const now = Date.now(); + const diff = Math.abs(now - timestamp); + + if (diff > TIMESTAMP_TOLERANCE_MS) { + return { valid: false, error: `Timestamp too old or too far in future (tolerance: ${TIMESTAMP_TOLERANCE_MS / 1000}s)` }; + } + + return { valid: true }; +} + +/** + * Verifies Ed25519 signature + * @param publicKey Base64-encoded Ed25519 public key (32 bytes) + * @param signature Base64-encoded Ed25519 signature (64 bytes) + * @param message Message that was signed (UTF-8 string) + * @returns true if signature is valid, false otherwise + */ +export async function verifyEd25519Signature( + publicKey: string, + signature: string, + message: string +): Promise { + 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 + const isValid = await ed25519.verify(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 }; +} diff --git a/src/storage/d1.ts b/src/storage/d1.ts index 1c95757..9f0334e 100644 --- a/src/storage/d1.ts +++ b/src/storage/d1.ts @@ -1,9 +1,21 @@ -import { Storage, Offer, IceCandidate, CreateOfferRequest, TopicInfo } from './types.ts'; +import { randomUUID } from '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 + /** - * D1 storage adapter for topic-based offer management using Cloudflare D1 - * NOTE: This implementation is a placeholder and needs to be fully tested + * D1 storage adapter for rondevu DNS-like system using Cloudflare D1 */ export class D1Storage implements Storage { private db: D1Database; @@ -17,11 +29,12 @@ export class D1Storage implements Storage { } /** - * Initializes database schema with new topic-based structure + * Initializes database schema with username and service-based structure * This should be run once during setup, not on every request */ async initializeDatabase(): Promise { await this.db.exec(` + -- Offers table (no topics) CREATE TABLE IF NOT EXISTS offers ( id TEXT PRIMARY KEY, peer_id TEXT NOT NULL, @@ -40,22 +53,13 @@ export class D1Storage implements Storage { CREATE INDEX IF NOT EXISTS idx_offers_last_seen ON offers(last_seen); CREATE INDEX IF NOT EXISTS idx_offers_answerer ON offers(answerer_peer_id); - CREATE TABLE IF NOT EXISTS offer_topics ( - offer_id TEXT NOT NULL, - topic TEXT NOT NULL, - PRIMARY KEY (offer_id, topic), - FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE - ); - - CREATE INDEX IF NOT EXISTS idx_topics_topic ON offer_topics(topic); - CREATE INDEX IF NOT EXISTS idx_topics_offer ON offer_topics(offer_id); - + -- ICE candidates table CREATE TABLE IF NOT EXISTS ice_candidates ( id INTEGER PRIMARY KEY AUTOINCREMENT, offer_id TEXT NOT NULL, peer_id TEXT NOT NULL, role TEXT NOT NULL CHECK(role IN ('offerer', 'answerer')), - candidate TEXT NOT NULL, -- JSON: RTCIceCandidateInit object + candidate TEXT NOT NULL, created_at INTEGER NOT NULL, FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE ); @@ -63,36 +67,76 @@ export class D1Storage implements Storage { CREATE INDEX IF NOT EXISTS idx_ice_offer ON ice_candidates(offer_id); CREATE INDEX IF NOT EXISTS idx_ice_peer ON ice_candidates(peer_id); CREATE INDEX IF NOT EXISTS idx_ice_created ON ice_candidates(created_at); + + -- Usernames table + CREATE TABLE IF NOT EXISTS usernames ( + username TEXT PRIMARY KEY, + public_key TEXT NOT NULL UNIQUE, + claimed_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + last_used INTEGER NOT NULL, + metadata TEXT, + CHECK(length(username) >= 3 AND length(username) <= 32) + ); + + CREATE INDEX IF NOT EXISTS idx_usernames_expires ON usernames(expires_at); + CREATE INDEX IF NOT EXISTS idx_usernames_public_key ON usernames(public_key); + + -- Services table + CREATE TABLE IF NOT EXISTS services ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + service_fqn TEXT NOT NULL, + offer_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + is_public INTEGER NOT NULL DEFAULT 0, + metadata TEXT, + FOREIGN KEY (username) REFERENCES usernames(username) ON DELETE CASCADE, + FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE, + UNIQUE(username, service_fqn) + ); + + CREATE INDEX IF NOT EXISTS idx_services_username ON services(username); + CREATE INDEX IF NOT EXISTS idx_services_fqn ON services(service_fqn); + CREATE INDEX IF NOT EXISTS idx_services_expires ON services(expires_at); + CREATE INDEX IF NOT EXISTS idx_services_offer ON services(offer_id); + + -- Service index table (privacy layer) + CREATE TABLE IF NOT EXISTS service_index ( + uuid TEXT PRIMARY KEY, + service_id TEXT NOT NULL, + username TEXT NOT NULL, + service_fqn TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_service_index_username ON service_index(username); + CREATE INDEX IF NOT EXISTS idx_service_index_expires ON service_index(expires_at); `); } + // ===== Offer Management ===== + async createOffers(offers: CreateOfferRequest[]): Promise { const created: Offer[] = []; // D1 doesn't support true transactions yet, so we do this sequentially for (const offer of offers) { - const id = offer.id || await generateOfferHash(offer.sdp, offer.topics); + const id = offer.id || await generateOfferHash(offer.sdp, []); const now = Date.now(); - // Insert offer await this.db.prepare(` INSERT INTO offers (id, peer_id, sdp, created_at, expires_at, last_seen, secret) VALUES (?, ?, ?, ?, ?, ?, ?) `).bind(id, offer.peerId, offer.sdp, now, offer.expiresAt, now, offer.secret || null).run(); - // Insert topics - for (const topic of offer.topics) { - await this.db.prepare(` - INSERT INTO offer_topics (offer_id, topic) - VALUES (?, ?) - `).bind(id, topic).run(); - } - created.push({ id, peerId: offer.peerId, sdp: offer.sdp, - topics: offer.topics, createdAt: now, expiresAt: offer.expiresAt, lastSeen: now, @@ -103,33 +147,6 @@ export class D1Storage implements Storage { return created; } - async getOffersByTopic(topic: string, excludePeerIds?: string[]): Promise { - 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 { const result = await this.db.prepare(` SELECT * FROM offers @@ -141,7 +158,7 @@ export class D1Storage implements Storage { return []; } - return Promise.all(result.results.map(row => this.rowToOffer(row as any))); + return result.results.map(row => this.rowToOffer(row as any)); } async getOfferById(offerId: string): Promise { @@ -234,21 +251,20 @@ export class D1Storage implements Storage { return []; } - return Promise.all(result.results.map(row => this.rowToOffer(row as any))); + return result.results.map(row => this.rowToOffer(row as any)); } + // ===== ICE Candidate Management ===== + async addIceCandidates( offerId: string, peerId: string, role: 'offerer' | 'answerer', candidates: any[] ): Promise { - console.log(`[D1] addIceCandidates: offerId=${offerId}, peerId=${peerId}, role=${role}, count=${candidates.length}`); - - // Give each candidate a unique timestamp to avoid "since" filtering issues // D1 doesn't have transactions, so insert one by one for (let i = 0; i < candidates.length; i++) { - const timestamp = Date.now() + i; // Ensure unique timestamps + const timestamp = Date.now() + i; await this.db.prepare(` INSERT INTO ice_candidates (offer_id, peer_id, role, candidate, created_at) VALUES (?, ?, ?, ?, ?) @@ -256,7 +272,7 @@ export class D1Storage implements Storage { offerId, peerId, role, - JSON.stringify(candidates[i]), // Store full object as JSON + JSON.stringify(candidates[i]), timestamp ).run(); } @@ -283,82 +299,232 @@ export class D1Storage implements Storage { query += ' ORDER BY created_at ASC'; - console.log(`[D1] getIceCandidates query: offerId=${offerId}, targetRole=${targetRole}, since=${since}`); const result = await this.db.prepare(query).bind(...params).all(); - console.log(`[D1] getIceCandidates result: ${result.results?.length || 0} rows`); if (!result.results) { return []; } - const candidates = result.results.map((row: any) => ({ + return result.results.map((row: any) => ({ id: row.id, offerId: row.offer_id, peerId: row.peer_id, role: row.role, - candidate: JSON.parse(row.candidate), // Parse JSON back to object + candidate: JSON.parse(row.candidate), createdAt: row.created_at, })); - - if (candidates.length > 0) { - console.log(`[D1] First candidate createdAt: ${candidates[0].createdAt}, since: ${since}`); - } - - return candidates; } - async getTopics(limit: number, offset: number, startsWith?: string): Promise<{ - topics: TopicInfo[]; - total: number; + // ===== Username Management ===== + + async claimUsername(request: ClaimUsernameRequest): Promise { + const now = Date.now(); + const expiresAt = now + YEAR_IN_MS; + + // Try to insert or update + const result = await this.db.prepare(` + INSERT INTO usernames (username, public_key, claimed_at, expires_at, last_used, metadata) + VALUES (?, ?, ?, ?, ?, NULL) + ON CONFLICT(username) DO UPDATE SET + expires_at = ?, + last_used = ? + WHERE public_key = ? + `).bind( + request.username, + request.publicKey, + now, + expiresAt, + now, + expiresAt, + now, + request.publicKey + ).run(); + + if ((result.meta.changes || 0) === 0) { + throw new Error('Username already claimed by different public key'); + } + + return { + username: request.username, + publicKey: request.publicKey, + claimedAt: now, + expiresAt, + lastUsed: now, + }; + } + + async getUsername(username: string): Promise { + 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 { + 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 { + const result = await this.db.prepare(` + DELETE FROM usernames WHERE expires_at < ? + `).bind(now).run(); + + return result.meta.changes || 0; + } + + // ===== Service Management ===== + + async createService(request: CreateServiceRequest): Promise<{ + service: Service; + indexUuid: string; }> { + const serviceId = 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 > ?'; + // Insert service + await this.db.prepare(` + INSERT INTO services (id, username, service_fqn, offer_id, created_at, expires_at, is_public, metadata) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `).bind( + serviceId, + request.username, + request.serviceFqn, + request.offerId, + now, + request.expiresAt, + request.isPublic ? 1 : 0, + request.metadata || null + ).run(); - const startsWithPattern = startsWith ? `${startsWith}%` : null; + // Insert service index + await this.db.prepare(` + INSERT INTO service_index (uuid, service_id, username, service_fqn, created_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?) + `).bind( + indexUuid, + serviceId, + request.username, + request.serviceFqn, + now, + request.expiresAt + ).run(); - // Get total count of topics with active offers - const countQuery = ` - SELECT COUNT(DISTINCT ot.topic) as count - FROM offer_topics ot - INNER JOIN offers o ON ot.offer_id = o.id - WHERE ${whereClause} - `; + // Touch username to extend expiry + await this.touchUsername(request.username); - const countStmt = this.db.prepare(countQuery); - const countResult = startsWith - ? await countStmt.bind(now, startsWithPattern).first() - : await countStmt.bind(now).first(); + return { + service: { + id: serviceId, + username: request.username, + serviceFqn: request.serviceFqn, + offerId: request.offerId, + createdAt: now, + expiresAt: request.expiresAt, + isPublic: request.isPublic || false, + metadata: request.metadata, + }, + indexUuid, + }; + } - const total = (countResult as any)?.count || 0; + async getServiceById(serviceId: string): Promise { + const result = await this.db.prepare(` + SELECT * FROM services + WHERE id = ? AND expires_at > ? + `).bind(serviceId, Date.now()).first(); - // Get topics with peer counts (paginated) - const topicsQuery = ` - SELECT - ot.topic, - COUNT(DISTINCT o.peer_id) as active_peers - FROM offer_topics ot - INNER JOIN offers o ON ot.offer_id = o.id - WHERE ${whereClause} - GROUP BY ot.topic - ORDER BY active_peers DESC, ot.topic ASC - LIMIT ? OFFSET ? - `; + if (!result) { + return null; + } - const topicsStmt = this.db.prepare(topicsQuery); - const topicsResult = startsWith - ? await topicsStmt.bind(now, startsWithPattern, limit, offset).all() - : await topicsStmt.bind(now, limit, offset).all(); + return this.rowToService(result as any); + } - const topics = (topicsResult.results || []).map((row: any) => ({ - topic: row.topic, - activePeers: row.active_peers, + async getServiceByUuid(uuid: string): Promise { + 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 { + const result = await this.db.prepare(` + SELECT si.uuid, s.is_public, s.service_fqn, s.metadata + FROM service_index si + INNER JOIN services s ON si.service_id = s.id + WHERE si.username = ? AND si.expires_at > ? + ORDER BY s.created_at DESC + `).bind(username, Date.now()).all(); + + if (!result.results) { + return []; + } + + return result.results.map((row: any) => ({ + uuid: row.uuid, + isPublic: row.is_public === 1, + serviceFqn: row.is_public === 1 ? row.service_fqn : undefined, + metadata: row.is_public === 1 ? row.metadata || undefined : undefined, })); + } - return { topics, total }; + async queryService(username: string, serviceFqn: string): Promise { + const result = await this.db.prepare(` + SELECT si.uuid FROM service_index si + INNER JOIN services s ON si.service_id = s.id + WHERE si.username = ? AND si.service_fqn = ? AND si.expires_at > ? + `).bind(username, serviceFqn, Date.now()).first(); + + return result ? (result as any).uuid : null; + } + + async deleteService(serviceId: string, username: string): Promise { + 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 { + const result = await this.db.prepare(` + DELETE FROM services WHERE expires_at < ? + `).bind(now).run(); + + return result.meta.changes || 0; } async close(): Promise { @@ -366,22 +532,16 @@ export class D1Storage implements Storage { // Connections are managed by the Cloudflare Workers runtime } + // ===== Helper Methods ===== + /** - * Helper method to convert database row to Offer object with topics + * Helper method to convert database row to Offer object */ - private async rowToOffer(row: any): Promise { - // Get topics for this offer - const topicResult = await this.db.prepare(` - SELECT topic FROM offer_topics WHERE offer_id = ? - `).bind(row.id).all(); - - const topics = topicResult.results?.map((t: any) => t.topic) || []; - + private rowToOffer(row: any): Offer { return { id: row.id, peerId: row.peer_id, sdp: row.sdp, - topics, createdAt: row.created_at, expiresAt: row.expires_at, lastSeen: row.last_seen, @@ -391,4 +551,20 @@ export class D1Storage implements Storage { answeredAt: row.answered_at || undefined, }; } + + /** + * Helper method to convert database row to Service object + */ + private rowToService(row: any): Service { + return { + id: row.id, + username: row.username, + serviceFqn: row.service_fqn, + offerId: row.offer_id, + createdAt: row.created_at, + expiresAt: row.expires_at, + isPublic: row.is_public === 1, + metadata: row.metadata || undefined, + }; + } } diff --git a/src/storage/sqlite.ts b/src/storage/sqlite.ts index 23d525f..01bf451 100644 --- a/src/storage/sqlite.ts +++ b/src/storage/sqlite.ts @@ -1,9 +1,22 @@ import Database from 'better-sqlite3'; -import { Storage, Offer, IceCandidate, CreateOfferRequest, TopicInfo } from './types.ts'; +import { randomUUID } from 'crypto'; +import { + Storage, + Offer, + IceCandidate, + CreateOfferRequest, + Username, + ClaimUsernameRequest, + Service, + CreateServiceRequest, + ServiceInfo, +} from './types.ts'; import { generateOfferHash } from './hash-id.ts'; +const YEAR_IN_MS = 365 * 24 * 60 * 60 * 1000; // 365 days + /** - * SQLite storage adapter for topic-based offer management + * SQLite storage adapter for rondevu DNS-like system * Supports both file-based and in-memory databases */ export class SQLiteStorage implements Storage { @@ -19,10 +32,11 @@ export class SQLiteStorage implements Storage { } /** - * Initializes database schema with new topic-based structure + * Initializes database schema with username and service-based structure */ private initializeDatabase(): void { this.db.exec(` + -- Offers table (no topics) CREATE TABLE IF NOT EXISTS offers ( id TEXT PRIMARY KEY, peer_id TEXT NOT NULL, @@ -41,22 +55,13 @@ export class SQLiteStorage implements Storage { CREATE INDEX IF NOT EXISTS idx_offers_last_seen ON offers(last_seen); CREATE INDEX IF NOT EXISTS idx_offers_answerer ON offers(answerer_peer_id); - CREATE TABLE IF NOT EXISTS offer_topics ( - offer_id TEXT NOT NULL, - topic TEXT NOT NULL, - PRIMARY KEY (offer_id, topic), - FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE - ); - - CREATE INDEX IF NOT EXISTS idx_topics_topic ON offer_topics(topic); - CREATE INDEX IF NOT EXISTS idx_topics_offer ON offer_topics(offer_id); - + -- ICE candidates table CREATE TABLE IF NOT EXISTS ice_candidates ( id INTEGER PRIMARY KEY AUTOINCREMENT, offer_id TEXT NOT NULL, peer_id TEXT NOT NULL, role TEXT NOT NULL CHECK(role IN ('offerer', 'answerer')), - candidate TEXT NOT NULL, -- JSON: RTCIceCandidateInit object + candidate TEXT NOT NULL, created_at INTEGER NOT NULL, FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE ); @@ -64,12 +69,62 @@ export class SQLiteStorage implements Storage { CREATE INDEX IF NOT EXISTS idx_ice_offer ON ice_candidates(offer_id); CREATE INDEX IF NOT EXISTS idx_ice_peer ON ice_candidates(peer_id); CREATE INDEX IF NOT EXISTS idx_ice_created ON ice_candidates(created_at); + + -- Usernames table + CREATE TABLE IF NOT EXISTS usernames ( + username TEXT PRIMARY KEY, + public_key TEXT NOT NULL UNIQUE, + claimed_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + last_used INTEGER NOT NULL, + metadata TEXT, + CHECK(length(username) >= 3 AND length(username) <= 32) + ); + + CREATE INDEX IF NOT EXISTS idx_usernames_expires ON usernames(expires_at); + CREATE INDEX IF NOT EXISTS idx_usernames_public_key ON usernames(public_key); + + -- Services table + CREATE TABLE IF NOT EXISTS services ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + service_fqn TEXT NOT NULL, + offer_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + is_public INTEGER NOT NULL DEFAULT 0, + metadata TEXT, + FOREIGN KEY (username) REFERENCES usernames(username) ON DELETE CASCADE, + FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE, + UNIQUE(username, service_fqn) + ); + + CREATE INDEX IF NOT EXISTS idx_services_username ON services(username); + CREATE INDEX IF NOT EXISTS idx_services_fqn ON services(service_fqn); + CREATE INDEX IF NOT EXISTS idx_services_expires ON services(expires_at); + CREATE INDEX IF NOT EXISTS idx_services_offer ON services(offer_id); + + -- Service index table (privacy layer) + CREATE TABLE IF NOT EXISTS service_index ( + uuid TEXT PRIMARY KEY, + service_id TEXT NOT NULL, + username TEXT NOT NULL, + service_fqn TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_service_index_username ON service_index(username); + CREATE INDEX IF NOT EXISTS idx_service_index_expires ON service_index(expires_at); `); // Enable foreign keys this.db.pragma('foreign_keys = ON'); } + // ===== Offer Management ===== + async createOffers(offers: CreateOfferRequest[]): Promise { const created: Offer[] = []; @@ -77,7 +132,7 @@ export class SQLiteStorage implements Storage { const offersWithIds = await Promise.all( offers.map(async (offer) => ({ ...offer, - id: offer.id || await generateOfferHash(offer.sdp, offer.topics), + id: offer.id || await generateOfferHash(offer.sdp, []), })) ); @@ -88,11 +143,6 @@ export class SQLiteStorage implements Storage { VALUES (?, ?, ?, ?, ?, ?, ?) `); - const topicStmt = this.db.prepare(` - INSERT INTO offer_topics (offer_id, topic) - VALUES (?, ?) - `); - for (const offer of offersWithIds) { const now = Date.now(); @@ -107,16 +157,10 @@ export class SQLiteStorage implements Storage { offer.secret || null ); - // Insert topics - for (const topic of offer.topics) { - topicStmt.run(offer.id, topic); - } - created.push({ id: offer.id, peerId: offer.peerId, sdp: offer.sdp, - topics: offer.topics, createdAt: now, expiresAt: offer.expiresAt, lastSeen: now, @@ -129,30 +173,6 @@ export class SQLiteStorage implements Storage { return created; } - async getOffersByTopic(topic: string, excludePeerIds?: string[]): Promise { - 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 { const stmt = this.db.prepare(` SELECT * FROM offers @@ -161,7 +181,7 @@ export class SQLiteStorage implements Storage { `); const rows = stmt.all(peerId, Date.now()) as any[]; - return Promise.all(rows.map(row => this.rowToOffer(row))); + return rows.map(row => this.rowToOffer(row)); } async getOfferById(offerId: string): Promise { @@ -254,9 +274,11 @@ export class SQLiteStorage implements Storage { `); const rows = stmt.all(offererPeerId, Date.now()) as any[]; - return Promise.all(rows.map(row => this.rowToOffer(row))); + return rows.map(row => this.rowToOffer(row)); } + // ===== ICE Candidate Management ===== + async addIceCandidates( offerId: string, peerId: string, @@ -275,8 +297,8 @@ export class SQLiteStorage implements Storage { offerId, peerId, role, - JSON.stringify(candidates[i]), // Store full object as JSON - baseTimestamp + i // Ensure unique timestamps to avoid "since" filtering issues + JSON.stringify(candidates[i]), + baseTimestamp + i ); } }); @@ -312,85 +334,249 @@ export class SQLiteStorage implements Storage { offerId: row.offer_id, peerId: row.peer_id, role: row.role, - candidate: JSON.parse(row.candidate), // Parse JSON back to object + candidate: JSON.parse(row.candidate), createdAt: row.created_at, })); } - async getTopics(limit: number, offset: number, startsWith?: string): Promise<{ - topics: TopicInfo[]; - total: number; + // ===== Username Management ===== + + async claimUsername(request: ClaimUsernameRequest): Promise { + 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 { + 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 { + 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 { + const stmt = this.db.prepare('DELETE FROM usernames WHERE expires_at < ?'); + const result = stmt.run(now); + return result.changes; + } + + // ===== Service Management ===== + + async createService(request: CreateServiceRequest): Promise<{ + service: Service; + indexUuid: string; }> { + const serviceId = randomUUID(); + const indexUuid = randomUUID(); const now = Date.now(); - // Build WHERE clause for startsWith filter - const whereClause = startsWith - ? 'o.expires_at > ? AND ot.topic LIKE ?' - : 'o.expires_at > ?'; + const transaction = this.db.transaction(() => { + // Insert service + const serviceStmt = this.db.prepare(` + INSERT INTO services (id, username, service_fqn, offer_id, created_at, expires_at, is_public, metadata) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `); - const startsWithPattern = startsWith ? `${startsWith}%` : null; + serviceStmt.run( + serviceId, + request.username, + request.serviceFqn, + request.offerId, + now, + request.expiresAt, + request.isPublic ? 1 : 0, + request.metadata || null + ); - // Get total count of topics with active offers - const countQuery = ` - SELECT COUNT(DISTINCT ot.topic) as count - FROM offer_topics ot - INNER JOIN offers o ON ot.offer_id = o.id - WHERE ${whereClause} - `; + // Insert service index + const indexStmt = this.db.prepare(` + INSERT INTO service_index (uuid, service_id, username, service_fqn, created_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?) + `); - const countStmt = this.db.prepare(countQuery); - const countParams = startsWith ? [now, startsWithPattern] : [now]; - const countRow = countStmt.get(...countParams) as any; - const total = countRow.count; + indexStmt.run( + indexUuid, + serviceId, + request.username, + request.serviceFqn, + now, + request.expiresAt + ); - // Get topics with peer counts (paginated) - const topicsQuery = ` - SELECT - ot.topic, - COUNT(DISTINCT o.peer_id) as active_peers - FROM offer_topics ot - INNER JOIN offers o ON ot.offer_id = o.id - WHERE ${whereClause} - GROUP BY ot.topic - ORDER BY active_peers DESC, ot.topic ASC - LIMIT ? OFFSET ? - `; + // Touch username to extend expiry + this.touchUsername(request.username); + }); - const topicsStmt = this.db.prepare(topicsQuery); - const topicsParams = startsWith - ? [now, startsWithPattern, limit, offset] - : [now, limit, offset]; - const rows = topicsStmt.all(...topicsParams) as any[]; + transaction(); - const topics = rows.map(row => ({ - topic: row.topic, - activePeers: row.active_peers, + return { + service: { + id: serviceId, + username: request.username, + serviceFqn: request.serviceFqn, + offerId: request.offerId, + createdAt: now, + expiresAt: request.expiresAt, + isPublic: request.isPublic || false, + metadata: request.metadata, + }, + indexUuid, + }; + } + + async getServiceById(serviceId: string): Promise { + 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 { + 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 { + const stmt = this.db.prepare(` + SELECT si.uuid, s.is_public, s.service_fqn, s.metadata + FROM service_index si + INNER JOIN services s ON si.service_id = s.id + WHERE si.username = ? AND si.expires_at > ? + ORDER BY s.created_at DESC + `); + + const rows = stmt.all(username, Date.now()) as any[]; + + return rows.map(row => ({ + uuid: row.uuid, + isPublic: row.is_public === 1, + serviceFqn: row.is_public === 1 ? row.service_fqn : undefined, + metadata: row.is_public === 1 ? row.metadata || undefined : undefined, })); + } - return { topics, total }; + async queryService(username: string, serviceFqn: string): Promise { + const stmt = this.db.prepare(` + SELECT si.uuid FROM service_index si + INNER JOIN services s ON si.service_id = s.id + WHERE si.username = ? AND si.service_fqn = ? AND si.expires_at > ? + `); + + const row = stmt.get(username, serviceFqn, Date.now()) as any; + + return row ? row.uuid : null; + } + + async deleteService(serviceId: string, username: string): Promise { + 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 { + const stmt = this.db.prepare('DELETE FROM services WHERE expires_at < ?'); + const result = stmt.run(now); + return result.changes; } async close(): Promise { 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 { - // Get topics for this offer - const topicStmt = this.db.prepare(` - SELECT topic FROM offer_topics WHERE offer_id = ? - `); - - const topicRows = topicStmt.all(row.id) as any[]; - const topics = topicRows.map(t => t.topic); - + private rowToOffer(row: any): Offer { return { id: row.id, peerId: row.peer_id, sdp: row.sdp, - topics, createdAt: row.created_at, expiresAt: row.expires_at, lastSeen: row.last_seen, @@ -400,4 +586,20 @@ export class SQLiteStorage implements Storage { answeredAt: row.answered_at || undefined, }; } + + /** + * Helper method to convert database row to Service object + */ + private rowToService(row: any): Service { + return { + id: row.id, + username: row.username, + serviceFqn: row.service_fqn, + offerId: row.offer_id, + createdAt: row.created_at, + expiresAt: row.expires_at, + isPublic: row.is_public === 1, + metadata: row.metadata || undefined, + }; + } } diff --git a/src/storage/types.ts b/src/storage/types.ts index eefaf08..501ab2d 100644 --- a/src/storage/types.ts +++ b/src/storage/types.ts @@ -1,11 +1,10 @@ /** - * Represents a WebRTC signaling offer with topic-based discovery + * Represents a WebRTC signaling offer (no topics) */ export interface Offer { id: string; peerId: string; sdp: string; - topics: string[]; createdAt: number; expiresAt: number; lastSeen: number; @@ -29,14 +28,6 @@ export interface IceCandidate { createdAt: number; } -/** - * Represents a topic with active peer count - */ -export interface TopicInfo { - topic: string; - activePeers: number; -} - /** * Request to create a new offer */ @@ -44,17 +35,88 @@ export interface CreateOfferRequest { id?: string; peerId: string; sdp: string; - topics: string[]; expiresAt: number; secret?: string; info?: string; } /** - * Storage interface for offer management with topic-based discovery - * Implementations can use different backends (SQLite, D1, Memory, etc.) + * Represents a claimed username with cryptographic proof + */ +export interface Username { + username: string; + publicKey: string; // Base64-encoded Ed25519 public key + claimedAt: number; + expiresAt: number; // 365 days from claim/last use + lastUsed: number; + metadata?: string; // JSON optional user metadata +} + +/** + * Request to claim a username + */ +export interface ClaimUsernameRequest { + username: string; + publicKey: string; + signature: string; + message: string; // "claim:{username}:{timestamp}" +} + +/** + * Represents a published service + */ +export interface Service { + id: string; // UUID v4 + username: string; + serviceFqn: string; // com.example.chat@1.0.0 + offerId: string; // Links to offers table + createdAt: number; + expiresAt: number; + isPublic: boolean; + metadata?: string; // JSON service description +} + +/** + * Request to create a service + */ +export interface CreateServiceRequest { + username: string; + serviceFqn: string; + offerId: string; + expiresAt: number; + isPublic?: boolean; + metadata?: string; +} + +/** + * Represents a service index entry (privacy layer) + */ +export interface ServiceIndex { + uuid: string; // Random UUID for privacy + serviceId: string; + username: string; + serviceFqn: string; + createdAt: number; + expiresAt: number; +} + +/** + * Service info for discovery (privacy-aware) + */ +export interface ServiceInfo { + uuid: string; + isPublic: boolean; + serviceFqn?: string; // Only present if public + metadata?: string; // Only present if public +} + +/** + * Storage interface for rondevu DNS-like system + * Implementations can use different backends (SQLite, D1, etc.) */ export interface Storage { + // ===== Offer Management ===== + /** * Creates one or more offers * @param offers Array of offer creation requests @@ -62,14 +124,6 @@ export interface Storage { */ createOffers(offers: CreateOfferRequest[]): Promise; - /** - * 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; - /** * Retrieves all offers from a specific peer * @param peerId Peer identifier @@ -119,6 +173,8 @@ export interface Storage { */ getAnsweredOffers(offererPeerId: string): Promise; + // ===== ICE Candidate Management ===== + /** * Adds ICE candidates for an offer * @param offerId Offer identifier @@ -147,18 +203,92 @@ export interface Storage { since?: number ): Promise; + // ===== 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; + + /** + * Gets a username record + * @param username Username to look up + * @returns Username record if claimed, null otherwise + */ + getUsername(username: string): Promise; + + /** + * 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; + + /** + * Deletes all expired usernames + * @param now Current timestamp + * @returns Number of usernames deleted + */ + deleteExpiredUsernames(now: number): Promise; + + // ===== Service Management ===== + + /** + * Creates a new service + * @param request Service creation request + * @returns Created service with generated ID and index UUID + */ + createService(request: CreateServiceRequest): Promise<{ + service: Service; + indexUuid: string; }>; + /** + * Gets a service by its service ID + * @param serviceId Service ID + * @returns Service if found, null otherwise + */ + getServiceById(serviceId: string): Promise; + + /** + * Gets a service by its index UUID + * @param uuid Index UUID + * @returns Service if found, null otherwise + */ + getServiceByUuid(uuid: string): Promise; + + /** + * 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; + + /** + * 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; + + /** + * 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; + + /** + * Deletes all expired services + * @param now Current timestamp + * @returns Number of services deleted + */ + deleteExpiredServices(now: number): Promise; + /** * Closes the storage connection and releases resources */