From 51fe405440a28264872a370441a088eb3729e2ab Mon Sep 17 00:00:00 2001 From: Bas van den Aakster Date: Wed, 10 Dec 2025 22:06:45 +0100 Subject: [PATCH] Unified Ed25519 authentication - remove peer_id/credentials system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE: Remove dual authentication system - Remove POST /register endpoint - no longer needed - Remove peer_id/secret credential-based auth - All authentication now uses username + Ed25519 signatures - Anonymous users can generate random usernames (anon-{timestamp}-{hex}) Database schema: - Rename peer_id → username in offers table - Rename answerer_peer_id → answerer_username in offers table - Rename peer_id → username in ice_candidates table - Remove secret column from offers table - Add FK constraints for username columns Storage layer: - Update D1 and SQLite implementations - All methods use username instead of peerId - Remove secret-related code Auth middleware: - Replace validateCredentials() with Ed25519 signature verification - Extract auth from request body (POST) or query params (GET) - Verify signature against username's public key - Validate message format and timestamp Crypto utilities: - Remove generatePeerId(), encryptPeerId(), decryptPeerId(), validateCredentials() - Add generateAnonymousUsername() - creates anon-{timestamp}-{random} - Add validateAuthMessage() - validates auth message format Config: - Remove authSecret from Config interface (no longer needed) All server endpoints updated to use getAuthenticatedUsername() --- migrations/fresh_schema.sql | 82 ++++----- src/app.ts | 74 ++++----- src/config.ts | 13 -- src/crypto.ts | 150 ++++------------- src/middleware/auth.ts | 87 +++++++--- src/storage/d1.ts | 82 ++++----- src/storage/sqlite.ts | 322 +++++++++++++++++------------------- src/storage/types.ts | 39 +++-- 8 files changed, 370 insertions(+), 479 deletions(-) diff --git a/migrations/fresh_schema.sql b/migrations/fresh_schema.sql index b968e57..24d8d08 100644 --- a/migrations/fresh_schema.sql +++ b/migrations/fresh_schema.sql @@ -1,4 +1,5 @@ --- Fresh schema for Rondevu v0.4.1+ +-- Fresh schema for Rondevu v0.5.0+ +-- Unified Ed25519 authentication - username/keypair only -- This is the complete schema without migration steps -- Drop existing tables if they exist @@ -7,44 +8,7 @@ DROP TABLE IF EXISTS services; DROP TABLE IF EXISTS offers; DROP TABLE IF EXISTS usernames; --- Offers table -CREATE TABLE 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, - last_seen INTEGER NOT NULL, - secret TEXT, - answerer_peer_id TEXT, - answer_sdp TEXT, - answered_at INTEGER -); - -CREATE INDEX idx_offers_peer ON offers(peer_id); -CREATE INDEX idx_offers_service ON offers(service_id); -CREATE INDEX idx_offers_expires ON offers(expires_at); -CREATE INDEX idx_offers_last_seen ON offers(last_seen); -CREATE INDEX idx_offers_answerer ON offers(answerer_peer_id); - --- ICE candidates table -CREATE TABLE 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 idx_ice_offer ON ice_candidates(offer_id); -CREATE INDEX idx_ice_peer ON ice_candidates(peer_id); -CREATE INDEX idx_ice_role ON ice_candidates(role); -CREATE INDEX idx_ice_created ON ice_candidates(created_at); - --- Usernames table +-- Usernames table (now required for all users, even anonymous) CREATE TABLE usernames ( username TEXT PRIMARY KEY, public_key TEXT NOT NULL UNIQUE, @@ -75,3 +39,43 @@ CREATE INDEX idx_services_fqn ON services(service_fqn); CREATE INDEX idx_services_discovery ON services(service_name, version); CREATE INDEX idx_services_username ON services(username); CREATE INDEX idx_services_expires ON services(expires_at); + +-- Offers table (now uses username instead of peer_id) +CREATE TABLE offers ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + service_id TEXT, + service_fqn TEXT, + sdp TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + last_seen INTEGER NOT NULL, + answerer_username TEXT, + answer_sdp TEXT, + answered_at INTEGER, + FOREIGN KEY (username) REFERENCES usernames(username) ON DELETE CASCADE, + FOREIGN KEY (answerer_username) REFERENCES usernames(username) ON DELETE SET NULL +); + +CREATE INDEX idx_offers_username ON offers(username); +CREATE INDEX idx_offers_service ON offers(service_id); +CREATE INDEX idx_offers_expires ON offers(expires_at); +CREATE INDEX idx_offers_last_seen ON offers(last_seen); +CREATE INDEX idx_offers_answerer ON offers(answerer_username); + +-- ICE candidates table (now uses username instead of peer_id) +CREATE TABLE ice_candidates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + offer_id TEXT NOT NULL, + username 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, + FOREIGN KEY (username) REFERENCES usernames(username) ON DELETE CASCADE +); + +CREATE INDEX idx_ice_offer ON ice_candidates(offer_id); +CREATE INDEX idx_ice_username ON ice_candidates(username); +CREATE INDEX idx_ice_role ON ice_candidates(role); +CREATE INDEX idx_ice_created ON ice_candidates(created_at); diff --git a/src/app.ts b/src/app.ts index 6328657..425672f 100644 --- a/src/app.ts +++ b/src/app.ts @@ -2,8 +2,8 @@ import { Hono } from 'hono'; 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, validateUsernameClaim, validateServicePublish, validateServiceFqn, parseServiceFqn, isVersionCompatible } from './crypto.ts'; +import { createAuthMiddleware, getAuthenticatedUsername } from './middleware/auth.ts'; +import { validateUsernameClaim, validateServicePublish, validateServiceFqn, parseServiceFqn, isVersionCompatible } from './crypto.ts'; import type { Context } from 'hono'; /** @@ -14,7 +14,7 @@ export function createApp(storage: Storage, config: Config) { const app = new Hono(); // Create auth middleware - const authMiddleware = createAuthMiddleware(config.authSecret); + const authMiddleware = createAuthMiddleware(storage); // Enable CORS app.use('/*', cors({ @@ -60,24 +60,6 @@ export function createApp(storage: Storage, config: Config) { }); }); - /** - * POST /register - * Register a new peer - */ - app.post('/register', async (c) => { - try { - const peerId = generatePeerId(); - const secret = await encryptPeerId(peerId, config.authSecret); - - return c.json({ - peerId, - secret - }, 200); - } catch (err) { - console.error('Error registering peer:', err); - return c.json({ error: 'Internal server error' }, 500); - } - }); // ===== User Management (RESTful) ===== @@ -192,7 +174,7 @@ export function createApp(storage: Storage, config: Config) { // Get available offer from this service const serviceOffers = await storage.getOffersForService(service.id); - const availableOffer = serviceOffers.find(offer => !offer.answererPeerId); + const availableOffer = serviceOffers.find(offer => !offer.answererUsername); if (!availableOffer) { return c.json({ @@ -231,7 +213,7 @@ export function createApp(storage: Storage, config: Config) { const servicesWithOffers = await Promise.all( services.map(async (service) => { const offers = await storage.getOffersForService(service.id); - const availableOffer = offers.find(offer => !offer.answererPeerId); + const availableOffer = offers.find(offer => !offer.answererUsername); return availableOffer ? { serviceId: service.id, username: service.username, @@ -265,7 +247,7 @@ export function createApp(storage: Storage, config: Config) { // Get available offer const offers = await storage.getOffersForService(service.id); - const availableOffer = offers.find(offer => !offer.answererPeerId); + const availableOffer = offers.find(offer => !offer.answererUsername); if (!availableOffer) { return c.json({ @@ -351,7 +333,7 @@ export function createApp(storage: Storage, config: Config) { } // Calculate expiry - const peerId = getAuthenticatedPeerId(c); + const authenticatedUsername = getAuthenticatedUsername(c); const offerTtl = Math.min( Math.max(ttl || config.offerDefaultTtl, config.offerMinTtl), config.offerMaxTtl @@ -360,7 +342,7 @@ export function createApp(storage: Storage, config: Config) { // Prepare offer requests const offerRequests = offers.map(offer => ({ - peerId, + username: authenticatedUsername, sdp: offer.sdp, expiresAt })); @@ -469,9 +451,9 @@ export function createApp(storage: Storage, config: Config) { return c.json({ error: 'Offer not found' }, 404); } - const answererPeerId = getAuthenticatedPeerId(c); + const answererUsername = getAuthenticatedUsername(c); - const result = await storage.answerOffer(offerId, answererPeerId, sdp); + const result = await storage.answerOffer(offerId, answererUsername, sdp); if (!result.success) { return c.json({ error: result.error }, 400); @@ -495,7 +477,7 @@ export function createApp(storage: Storage, config: Config) { try { const serviceFqn = decodeURIComponent(c.req.param('fqn')); const offerId = c.req.param('offerId'); - const peerId = getAuthenticatedPeerId(c); + const username = getAuthenticatedUsername(c); // Get the offer const offer = await storage.getOfferById(offerId); @@ -504,7 +486,7 @@ export function createApp(storage: Storage, config: Config) { } // Verify ownership - if (offer.peerId !== peerId) { + if (offer.username !== username) { return c.json({ error: 'Not authorized to access this offer' }, 403); } @@ -514,7 +496,7 @@ export function createApp(storage: Storage, config: Config) { return c.json({ offerId: offer.id, - answererId: offer.answererPeerId, + answererId: offer.answererUsername, sdp: offer.answerSdp, answeredAt: offer.answeredAt }, 200); @@ -530,11 +512,11 @@ export function createApp(storage: Storage, config: Config) { */ app.get('/offers/answered', authMiddleware, async (c) => { try { - const peerId = getAuthenticatedPeerId(c); + const username = getAuthenticatedUsername(c); const since = c.req.query('since'); const sinceTimestamp = since ? parseInt(since, 10) : 0; - const offers = await storage.getAnsweredOffers(peerId); + const offers = await storage.getAnsweredOffers(username); // Filter by timestamp if provided const filteredOffers = since @@ -545,7 +527,7 @@ export function createApp(storage: Storage, config: Config) { offers: filteredOffers.map(offer => ({ offerId: offer.id, serviceId: offer.serviceId, - answererId: offer.answererPeerId, + answererId: offer.answererUsername, sdp: offer.answerSdp, answeredAt: offer.answeredAt })) @@ -563,18 +545,18 @@ export function createApp(storage: Storage, config: Config) { */ app.get('/offers/poll', authMiddleware, async (c) => { try { - const peerId = getAuthenticatedPeerId(c); + const username = getAuthenticatedUsername(c); const since = c.req.query('since'); const sinceTimestamp = since ? parseInt(since, 10) : 0; // Get all answered offers - const answeredOffers = await storage.getAnsweredOffers(peerId); + const answeredOffers = await storage.getAnsweredOffers(username); const filteredAnswers = since ? answeredOffers.filter(offer => offer.answeredAt && offer.answeredAt > sinceTimestamp) : answeredOffers; - // Get all peer's offers - const allOffers = await storage.getOffersByPeerId(peerId); + // Get all user's offers + const allOffers = await storage.getOffersByUsername(username); // For each offer, get ICE candidates from both sides const iceCandidatesByOffer: Record = {}; @@ -587,7 +569,7 @@ export function createApp(storage: Storage, config: Config) { allCandidates.push({ candidate: c.candidate, role: 'offerer', - peerId: c.peerId, + username: c.username, createdAt: c.createdAt }); } @@ -598,7 +580,7 @@ export function createApp(storage: Storage, config: Config) { allCandidates.push({ candidate: c.candidate, role: 'answerer', - peerId: c.peerId, + username: c.username, createdAt: c.createdAt }); } @@ -612,7 +594,7 @@ export function createApp(storage: Storage, config: Config) { answers: filteredAnswers.map(offer => ({ offerId: offer.id, serviceId: offer.serviceId, - answererId: offer.answererPeerId, + answererId: offer.answererUsername, sdp: offer.answerSdp, answeredAt: offer.answeredAt })), @@ -639,7 +621,7 @@ export function createApp(storage: Storage, config: Config) { return c.json({ error: 'Missing or invalid required parameter: candidates' }, 400); } - const peerId = getAuthenticatedPeerId(c); + const username = getAuthenticatedUsername(c); // Get offer to determine role const offer = await storage.getOfferById(offerId); @@ -648,9 +630,9 @@ export function createApp(storage: Storage, config: Config) { } // Determine role (offerer or answerer) - const role = offer.peerId === peerId ? 'offerer' : 'answerer'; + const role = offer.username === username ? 'offerer' : 'answerer'; - const count = await storage.addIceCandidates(offerId, peerId, role, candidates); + const count = await storage.addIceCandidates(offerId, username, role, candidates); return c.json({ count, offerId }, 200); } catch (err) { @@ -668,7 +650,7 @@ export function createApp(storage: Storage, config: Config) { const serviceFqn = decodeURIComponent(c.req.param('fqn')); const offerId = c.req.param('offerId'); const since = c.req.query('since'); - const peerId = getAuthenticatedPeerId(c); + const username = getAuthenticatedUsername(c); // Get offer to determine role const offer = await storage.getOfferById(offerId); @@ -677,7 +659,7 @@ export function createApp(storage: Storage, config: Config) { } // Get candidates for opposite role - const targetRole = offer.peerId === peerId ? 'answerer' : 'offerer'; + const targetRole = offer.username === username ? 'answerer' : 'offerer'; const sinceTimestamp = since ? parseInt(since, 10) : undefined; const candidates = await storage.getIceCandidates(offerId, targetRole, sinceTimestamp); diff --git a/src/config.ts b/src/config.ts index 1a4b013..57542fa 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,5 +1,3 @@ -import { generateSecretKey } from './crypto.ts'; - /** * Application configuration * Reads from environment variables with sensible defaults @@ -10,7 +8,6 @@ export interface Config { storagePath: string; corsOrigins: string[]; version: string; - authSecret: string; offerDefaultTtl: number; offerMaxTtl: number; offerMinTtl: number; @@ -22,15 +19,6 @@ export interface Config { * Loads configuration from environment variables */ export function loadConfig(): Config { - // Generate or load auth secret - let authSecret = process.env.AUTH_SECRET; - if (!authSecret) { - authSecret = generateSecretKey(); - console.warn('WARNING: No AUTH_SECRET provided. Generated temporary secret:', authSecret); - console.warn('All peer credentials will be invalidated on server restart.'); - console.warn('Set AUTH_SECRET environment variable to persist credentials across restarts.'); - } - return { port: parseInt(process.env.PORT || '3000', 10), storageType: (process.env.STORAGE_TYPE || 'sqlite') as 'sqlite' | 'memory', @@ -39,7 +27,6 @@ export function loadConfig(): Config { ? process.env.CORS_ORIGINS.split(',').map(o => o.trim()) : ['*'], version: process.env.VERSION || 'unknown', - authSecret, offerDefaultTtl: parseInt(process.env.OFFER_DEFAULT_TTL || '60000', 10), offerMaxTtl: parseInt(process.env.OFFER_MAX_TTL || '86400000', 10), offerMinTtl: parseInt(process.env.OFFER_MIN_TTL || '60000', 10), diff --git a/src/crypto.ts b/src/crypto.ts index b43b015..93e7851 100644 --- a/src/crypto.ts +++ b/src/crypto.ts @@ -1,7 +1,7 @@ /** - * Crypto utilities for stateless peer authentication - * Uses Web Crypto API for compatibility with both Node.js and Cloudflare Workers + * Crypto utilities for Ed25519-based authentication * Uses @noble/ed25519 for Ed25519 signature verification + * Uses Web Crypto API for compatibility with both Node.js and Cloudflare Workers */ import * as ed25519 from '@noble/ed25519'; @@ -12,10 +12,6 @@ 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; @@ -25,30 +21,15 @@ const USERNAME_MAX_LENGTH = 32; const TIMESTAMP_TOLERANCE_MS = 5 * 60 * 1000; /** - * Generates a random peer ID (16 bytes = 32 hex chars) + * Generates an anonymous username for users who don't want to claim one + * Format: anon-{timestamp}-{random} + * This reduces collision probability to near-zero */ -export function generatePeerId(): string { - const bytes = crypto.getRandomValues(new Uint8Array(16)); - return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''); -} - -/** - * Generates a random secret key for encryption (32 bytes = 64 hex chars) - */ -export function generateSecretKey(): string { - const bytes = crypto.getRandomValues(new Uint8Array(KEY_LENGTH)); - return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''); -} - -/** - * Convert hex string to Uint8Array - */ -function hexToBytes(hex: string): Uint8Array { - const bytes = new Uint8Array(hex.length / 2); - for (let i = 0; i < hex.length; i += 2) { - bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16); - } - return bytes; +export function generateAnonymousUsername(): string { + const timestamp = Date.now().toString(36); + const random = crypto.getRandomValues(new Uint8Array(3)); + const hex = Array.from(random).map(b => b.toString(16).padStart(2, '0')).join(''); + return `anon-${timestamp}-${hex}`; } /** @@ -70,99 +51,40 @@ function base64ToBytes(base64: string): Uint8Array { } /** - * Encrypts a peer ID using the server secret key - * Returns base64-encoded encrypted data (IV + ciphertext) + * Validates a generic auth message format + * Expected format: action:username:params:timestamp + * Validates that the message contains the expected username and has a valid timestamp */ -export async function encryptPeerId(peerId: string, secretKeyHex: string): Promise { - const keyBytes = hexToBytes(secretKeyHex); +export function validateAuthMessage( + expectedUsername: string, + message: string +): { valid: boolean; error?: string } { + const parts = message.split(':'); - if (keyBytes.length !== KEY_LENGTH) { - throw new Error(`Secret key must be ${KEY_LENGTH * 2} hex characters (${KEY_LENGTH} bytes)`); + if (parts.length < 3) { + return { valid: false, error: 'Invalid message format: must have at least action:username:timestamp' }; } - // Import key - const key = await crypto.subtle.importKey( - 'raw', - keyBytes, - { name: ALGORITHM, length: 256 }, - false, - ['encrypt'] - ); + // Extract username (second part) and timestamp (last part) + const messageUsername = parts[1]; + const timestamp = parseInt(parts[parts.length - 1], 10); - // Generate random IV - const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH)); - - // Encrypt peer ID - const encoder = new TextEncoder(); - const data = encoder.encode(peerId); - - const encrypted = await crypto.subtle.encrypt( - { name: ALGORITHM, iv }, - key, - data - ); - - // Combine IV + ciphertext and encode as base64 - const combined = new Uint8Array(iv.length + encrypted.byteLength); - combined.set(iv, 0); - combined.set(new Uint8Array(encrypted), iv.length); - - return bytesToBase64(combined); -} - -/** - * Decrypts an encrypted peer ID secret - * Returns the plaintext peer ID or throws if decryption fails - */ -export async function decryptPeerId(encryptedSecret: string, secretKeyHex: string): Promise { - try { - const keyBytes = hexToBytes(secretKeyHex); - - if (keyBytes.length !== KEY_LENGTH) { - throw new Error(`Secret key must be ${KEY_LENGTH * 2} hex characters (${KEY_LENGTH} bytes)`); - } - - // Decode base64 - const combined = base64ToBytes(encryptedSecret); - - // Extract IV and ciphertext - const iv = combined.slice(0, IV_LENGTH); - const ciphertext = combined.slice(IV_LENGTH); - - // Import key - const key = await crypto.subtle.importKey( - 'raw', - keyBytes, - { name: ALGORITHM, length: 256 }, - false, - ['decrypt'] - ); - - // Decrypt - const decrypted = await crypto.subtle.decrypt( - { name: ALGORITHM, iv }, - key, - ciphertext - ); - - const decoder = new TextDecoder(); - return decoder.decode(decrypted); - } catch (err) { - throw new Error('Failed to decrypt peer ID: invalid secret or secret key'); + // Validate username matches + if (messageUsername !== expectedUsername) { + return { valid: false, error: 'Username in message does not match authenticated username' }; } -} -/** - * Validates that a peer ID and secret match - * Returns true if valid, false otherwise - */ -export async function validateCredentials(peerId: string, encryptedSecret: string, secretKey: string): Promise { - try { - const decryptedPeerId = await decryptPeerId(encryptedSecret, secretKey); - return decryptedPeerId === peerId; - } catch { - return false; + // Validate timestamp + if (isNaN(timestamp)) { + return { valid: false, error: 'Invalid timestamp in message' }; } + + const timestampCheck = validateTimestamp(timestamp); + if (!timestampCheck.valid) { + return timestampCheck; + } + + return { valid: true }; } // ===== Username and Ed25519 Signature Utilities ===== diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index b0e3586..5d3f556 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -1,51 +1,84 @@ import { Context, Next } from 'hono'; -import { validateCredentials } from '../crypto.ts'; +import { verifyEd25519Signature, validateAuthMessage } from '../crypto.ts'; +import { Storage } from '../storage/types.ts'; /** - * Authentication middleware for Rondevu - * Validates Bearer token in format: {peerId}:{encryptedSecret} + * Authentication middleware for Rondevu - Ed25519 signature-based + * Verifies username ownership via Ed25519 signatures + * + * For POST requests: Extracts username, signature, message from request body + * For GET requests: Extracts username, signature, message from query params */ -export function createAuthMiddleware(authSecret: string) { +export function createAuthMiddleware(storage: Storage) { return async (c: Context, next: Next) => { - const authHeader = c.req.header('Authorization'); + let username: string | undefined; + let signature: string | undefined; + let message: string | undefined; - if (!authHeader) { - return c.json({ error: 'Missing Authorization header' }, 401); + // Determine if this is a GET or POST request + if (c.req.method === 'GET') { + // Extract from query params + const query = c.req.query(); + username = query.username; + signature = query.signature; + message = query.message; + } else { + // Extract from request body + try { + const body = await c.req.json(); + username = body.username; + signature = body.signature; + message = body.message; + } catch (err) { + return c.json({ error: 'Invalid JSON body' }, 400); + } } - // Expect format: Bearer {peerId}:{secret} - const parts = authHeader.split(' '); - if (parts.length !== 2 || parts[0] !== 'Bearer') { - return c.json({ error: 'Invalid Authorization header format. Expected: Bearer {peerId}:{secret}' }, 401); + // Validate presence of auth fields + if (!username || !signature || !message) { + return c.json({ + error: 'Missing authentication fields: username, signature, and message are required' + }, 401); } - const credentials = parts[1].split(':'); - if (credentials.length !== 2) { - return c.json({ error: 'Invalid credentials format. Expected: {peerId}:{secret}' }, 401); + // Get username record to fetch public key + const usernameRecord = await storage.getUsername(username); + if (!usernameRecord) { + return c.json({ + error: `Username "${username}" is not claimed. Please claim username first.` + }, 401); } - const [peerId, encryptedSecret] = credentials; - - // Validate credentials (async operation) - const isValid = await validateCredentials(peerId, encryptedSecret, authSecret); + // Verify Ed25519 signature + const isValid = await verifyEd25519Signature( + usernameRecord.publicKey, + signature, + message + ); if (!isValid) { - return c.json({ error: 'Invalid credentials' }, 401); + return c.json({ error: 'Invalid signature' }, 401); } - // Attach peer ID to context for use in handlers - c.set('peerId', peerId); + // Validate message format and timestamp + const validation = validateAuthMessage(username, message); + if (!validation.valid) { + return c.json({ error: validation.error }, 401); + } + + // Store authenticated username in context + c.set('username', username); await next(); }; } /** - * Helper to get authenticated peer ID from context + * Helper to get authenticated username from context */ -export function getAuthenticatedPeerId(c: Context): string { - const peerId = c.get('peerId'); - if (!peerId) { - throw new Error('No authenticated peer ID in context'); +export function getAuthenticatedUsername(c: Context): string { + const username = c.get('username'); + if (!username) { + throw new Error('No authenticated username in context'); } - return peerId; + return username; } diff --git a/src/storage/d1.ts b/src/storage/d1.ts index d39f576..65714d4 100644 --- a/src/storage/d1.ts +++ b/src/storage/d1.ts @@ -37,29 +37,28 @@ export class D1Storage implements Storage { -- WebRTC signaling offers CREATE TABLE IF NOT EXISTS offers ( id TEXT PRIMARY KEY, - peer_id TEXT NOT NULL, + username TEXT NOT NULL, service_id TEXT, 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, + answerer_username 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_username ON offers(username); 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 INDEX IF NOT EXISTS idx_offers_answerer ON offers(answerer_username); -- 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, + username TEXT NOT NULL, role TEXT NOT NULL CHECK(role IN ('offerer', 'answerer')), candidate TEXT NOT NULL, created_at INTEGER NOT NULL, @@ -67,7 +66,7 @@ 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_username ON ice_candidates(username); CREATE INDEX IF NOT EXISTS idx_ice_created ON ice_candidates(created_at); -- Usernames table @@ -115,31 +114,31 @@ export class D1Storage implements Storage { const now = Date.now(); await this.db.prepare(` - INSERT INTO offers (id, peer_id, service_id, sdp, created_at, expires_at, last_seen, secret) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `).bind(id, offer.peerId, offer.serviceId || null, offer.sdp, now, offer.expiresAt, now, offer.secret || null).run(); + INSERT INTO offers (id, username, service_id, sdp, created_at, expires_at, last_seen) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).bind(id, offer.username, offer.serviceId || null, offer.sdp, now, offer.expiresAt, now).run(); created.push({ id, - peerId: offer.peerId, + username: offer.username, serviceId: offer.serviceId, + serviceFqn: offer.serviceFqn, sdp: offer.sdp, createdAt: now, expiresAt: offer.expiresAt, lastSeen: now, - secret: offer.secret, }); } return created; } - async getOffersByPeerId(peerId: string): Promise { + async getOffersByUsername(username: string): Promise { const result = await this.db.prepare(` SELECT * FROM offers - WHERE peer_id = ? AND expires_at > ? + WHERE username = ? AND expires_at > ? ORDER BY last_seen DESC - `).bind(peerId, Date.now()).all(); + `).bind(username, Date.now()).all(); if (!result.results) { return []; @@ -161,11 +160,11 @@ export class D1Storage implements Storage { return this.rowToOffer(result as any); } - async deleteOffer(offerId: string, ownerPeerId: string): Promise { + async deleteOffer(offerId: string, ownerUsername: string): Promise { const result = await this.db.prepare(` DELETE FROM offers - WHERE id = ? AND peer_id = ? - `).bind(offerId, ownerPeerId).run(); + WHERE id = ? AND username = ? + `).bind(offerId, ownerUsername).run(); return (result.meta.changes || 0) > 0; } @@ -180,9 +179,8 @@ export class D1Storage implements Storage { async answerOffer( offerId: string, - answererPeerId: string, - answerSdp: string, - secret?: string + answererUsername: string, + answerSdp: string ): Promise<{ success: boolean; error?: string }> { // Check if offer exists and is not expired const offer = await this.getOfferById(offerId); @@ -194,16 +192,8 @@ export class D1Storage implements Storage { }; } - // Verify secret if offer is protected - if (offer.secret && offer.secret !== secret) { - return { - success: false, - error: 'Invalid or missing secret' - }; - } - // Check if offer already has an answerer - if (offer.answererPeerId) { + if (offer.answererUsername) { return { success: false, error: 'Offer already answered' @@ -213,9 +203,9 @@ export class D1Storage implements Storage { // Update offer with answer const result = await this.db.prepare(` UPDATE offers - SET answerer_peer_id = ?, answer_sdp = ?, answered_at = ? - WHERE id = ? AND answerer_peer_id IS NULL - `).bind(answererPeerId, answerSdp, Date.now(), offerId).run(); + SET answerer_username = ?, answer_sdp = ?, answered_at = ? + WHERE id = ? AND answerer_username IS NULL + `).bind(answererUsername, answerSdp, Date.now(), offerId).run(); if ((result.meta.changes || 0) === 0) { return { @@ -227,12 +217,12 @@ export class D1Storage implements Storage { return { success: true }; } - async getAnsweredOffers(offererPeerId: string): Promise { + async getAnsweredOffers(offererUsername: string): Promise { const result = await this.db.prepare(` SELECT * FROM offers - WHERE peer_id = ? AND answerer_peer_id IS NOT NULL AND expires_at > ? + WHERE username = ? AND answerer_username IS NOT NULL AND expires_at > ? ORDER BY answered_at DESC - `).bind(offererPeerId, Date.now()).all(); + `).bind(offererUsername, Date.now()).all(); if (!result.results) { return []; @@ -245,7 +235,7 @@ export class D1Storage implements Storage { async addIceCandidates( offerId: string, - peerId: string, + username: string, role: 'offerer' | 'answerer', candidates: any[] ): Promise { @@ -253,11 +243,11 @@ export class D1Storage implements Storage { for (let i = 0; i < candidates.length; i++) { const timestamp = Date.now() + i; await this.db.prepare(` - INSERT INTO ice_candidates (offer_id, peer_id, role, candidate, created_at) + INSERT INTO ice_candidates (offer_id, username, role, candidate, created_at) VALUES (?, ?, ?, ?, ?) `).bind( offerId, - peerId, + username, role, JSON.stringify(candidates[i]), timestamp @@ -295,7 +285,7 @@ export class D1Storage implements Storage { return result.results.map((row: any) => ({ id: row.id, offerId: row.offer_id, - peerId: row.peer_id, + username: row.username, role: row.role, candidate: JSON.parse(row.candidate), createdAt: row.created_at, @@ -513,14 +503,14 @@ export class D1Storage implements Storage { offset: number ): Promise { // Query for unique services with available offers - // We join with offers and filter for available ones (answerer_peer_id IS NULL) + // We join with offers and filter for available ones (answerer_username IS NULL) const result = await this.db.prepare(` SELECT DISTINCT s.* FROM services s INNER JOIN offers o ON o.service_id = s.id WHERE s.service_name = ? AND s.version = ? AND s.expires_at > ? - AND o.answerer_peer_id IS NULL + AND o.answerer_username IS NULL AND o.expires_at > ? ORDER BY s.created_at DESC LIMIT ? OFFSET ? @@ -541,7 +531,7 @@ export class D1Storage implements Storage { WHERE s.service_name = ? AND s.version = ? AND s.expires_at > ? - AND o.answerer_peer_id IS NULL + AND o.answerer_username IS NULL AND o.expires_at > ? ORDER BY RANDOM() LIMIT 1 @@ -584,14 +574,14 @@ export class D1Storage implements Storage { private rowToOffer(row: any): Offer { return { id: row.id, - peerId: row.peer_id, + username: row.username, serviceId: row.service_id || undefined, + serviceFqn: row.service_fqn || undefined, sdp: row.sdp, createdAt: row.created_at, expiresAt: row.expires_at, lastSeen: row.last_seen, - secret: row.secret || undefined, - answererPeerId: row.answerer_peer_id || undefined, + answererUsername: row.answerer_username || undefined, answerSdp: row.answer_sdp || undefined, answeredAt: row.answered_at || undefined, }; diff --git a/src/storage/sqlite.ts b/src/storage/sqlite.ts index c39cc33..c2bd114 100644 --- a/src/storage/sqlite.ts +++ b/src/storage/sqlite.ts @@ -9,9 +9,9 @@ import { ClaimUsernameRequest, Service, CreateServiceRequest, - ServiceInfo, } from './types.ts'; import { generateOfferHash } from './hash-id.ts'; +import { parseServiceFqn } from '../crypto.ts'; const YEAR_IN_MS = 365 * 24 * 60 * 60 * 1000; // 365 days @@ -39,30 +39,29 @@ export class SQLiteStorage implements Storage { -- WebRTC signaling offers CREATE TABLE IF NOT EXISTS offers ( id TEXT PRIMARY KEY, - peer_id TEXT NOT NULL, + username TEXT NOT NULL, service_id TEXT, 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, + answerer_username TEXT, answer_sdp TEXT, 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_username ON offers(username); 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 INDEX IF NOT EXISTS idx_offers_answerer ON offers(answerer_username); -- 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, + username TEXT NOT NULL, role TEXT NOT NULL CHECK(role IN ('offerer', 'answerer')), candidate TEXT NOT NULL, created_at INTEGER NOT NULL, @@ -70,7 +69,7 @@ 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_username ON ice_candidates(username); CREATE INDEX IF NOT EXISTS idx_ice_created ON ice_candidates(created_at); -- Usernames table @@ -87,36 +86,23 @@ export class SQLiteStorage implements Storage { 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) + -- Services table (new schema with extracted fields for discovery) CREATE TABLE IF NOT EXISTS services ( id TEXT PRIMARY KEY, - username TEXT NOT NULL, service_fqn TEXT NOT NULL, + service_name TEXT NOT NULL, + version TEXT NOT NULL, + username 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) + UNIQUE(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_discovery ON services(service_name, version); + CREATE INDEX IF NOT EXISTS idx_services_username ON services(username); 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 @@ -139,8 +125,8 @@ export class SQLiteStorage implements Storage { // 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, service_id, sdp, created_at, expires_at, last_seen, secret) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO offers (id, username, service_id, sdp, created_at, expires_at, last_seen) + VALUES (?, ?, ?, ?, ?, ?, ?) `); for (const offer of offersWithIds) { @@ -149,24 +135,23 @@ export class SQLiteStorage implements Storage { // Insert offer offerStmt.run( offer.id, - offer.peerId, + offer.username, offer.serviceId || null, offer.sdp, now, offer.expiresAt, - now, - offer.secret || null + now ); created.push({ id: offer.id, - peerId: offer.peerId, + username: offer.username, serviceId: offer.serviceId || undefined, + serviceFqn: offer.serviceFqn, sdp: offer.sdp, createdAt: now, expiresAt: offer.expiresAt, lastSeen: now, - secret: offer.secret, }); } }); @@ -175,14 +160,14 @@ export class SQLiteStorage implements Storage { return created; } - async getOffersByPeerId(peerId: string): Promise { + async getOffersByUsername(username: string): Promise { const stmt = this.db.prepare(` SELECT * FROM offers - WHERE peer_id = ? AND expires_at > ? + WHERE username = ? AND expires_at > ? ORDER BY last_seen DESC `); - const rows = stmt.all(peerId, Date.now()) as any[]; + const rows = stmt.all(username, Date.now()) as any[]; return rows.map(row => this.rowToOffer(row)); } @@ -201,13 +186,13 @@ export class SQLiteStorage implements Storage { return this.rowToOffer(row); } - async deleteOffer(offerId: string, ownerPeerId: string): Promise { + async deleteOffer(offerId: string, ownerUsername: string): Promise { const stmt = this.db.prepare(` DELETE FROM offers - WHERE id = ? AND peer_id = ? + WHERE id = ? AND username = ? `); - const result = stmt.run(offerId, ownerPeerId); + const result = stmt.run(offerId, ownerUsername); return result.changes > 0; } @@ -219,9 +204,8 @@ export class SQLiteStorage implements Storage { async answerOffer( offerId: string, - answererPeerId: string, - answerSdp: string, - secret?: string + answererUsername: string, + answerSdp: string ): Promise<{ success: boolean; error?: string }> { // Check if offer exists and is not expired const offer = await this.getOfferById(offerId); @@ -233,16 +217,8 @@ export class SQLiteStorage implements Storage { }; } - // Verify secret if offer is protected - if (offer.secret && offer.secret !== secret) { - return { - success: false, - error: 'Invalid or missing secret' - }; - } - // Check if offer already has an answerer - if (offer.answererPeerId) { + if (offer.answererUsername) { return { success: false, error: 'Offer already answered' @@ -252,11 +228,11 @@ export class SQLiteStorage implements Storage { // Update offer with answer const stmt = this.db.prepare(` UPDATE offers - SET answerer_peer_id = ?, answer_sdp = ?, answered_at = ? - WHERE id = ? AND answerer_peer_id IS NULL + SET answerer_username = ?, answer_sdp = ?, answered_at = ? + WHERE id = ? AND answerer_username IS NULL `); - const result = stmt.run(answererPeerId, answerSdp, Date.now(), offerId); + const result = stmt.run(answererUsername, answerSdp, Date.now(), offerId); if (result.changes === 0) { return { @@ -268,14 +244,14 @@ export class SQLiteStorage implements Storage { return { success: true }; } - async getAnsweredOffers(offererPeerId: string): Promise { + async getAnsweredOffers(offererUsername: string): Promise { const stmt = this.db.prepare(` SELECT * FROM offers - WHERE peer_id = ? AND answerer_peer_id IS NOT NULL AND expires_at > ? + WHERE username = ? AND answerer_username IS NOT NULL AND expires_at > ? ORDER BY answered_at DESC `); - const rows = stmt.all(offererPeerId, Date.now()) as any[]; + const rows = stmt.all(offererUsername, Date.now()) as any[]; return rows.map(row => this.rowToOffer(row)); } @@ -283,12 +259,12 @@ export class SQLiteStorage implements Storage { async addIceCandidates( offerId: string, - peerId: string, + username: string, role: 'offerer' | 'answerer', candidates: any[] ): Promise { const stmt = this.db.prepare(` - INSERT INTO ice_candidates (offer_id, peer_id, role, candidate, created_at) + INSERT INTO ice_candidates (offer_id, username, role, candidate, created_at) VALUES (?, ?, ?, ?, ?) `); @@ -297,7 +273,7 @@ export class SQLiteStorage implements Storage { for (let i = 0; i < candidates.length; i++) { stmt.run( offerId, - peerId, + username, role, JSON.stringify(candidates[i]), baseTimestamp + i @@ -334,7 +310,7 @@ export class SQLiteStorage implements Storage { return rows.map(row => ({ id: row.id, offerId: row.offer_id, - peerId: row.peer_id, + username: row.username, role: row.role, candidate: JSON.parse(row.candidate), createdAt: row.created_at, @@ -427,87 +403,96 @@ export class SQLiteStorage implements Storage { async createService(request: CreateServiceRequest): Promise<{ service: Service; - indexUuid: string; offers: Offer[]; }> { const serviceId = randomUUID(); - const indexUuid = randomUUID(); const now = Date.now(); - // Create offers with serviceId - const offerRequests: CreateOfferRequest[] = request.offers.map(offer => ({ - ...offer, - serviceId, - })); + // Parse FQN to extract components + const parsed = parseServiceFqn(request.serviceFqn); + if (!parsed) { + throw new Error(`Invalid service FQN: ${request.serviceFqn}`); + } + if (!parsed.username) { + throw new Error(`Service FQN must include username: ${request.serviceFqn}`); + } - const offers = await this.createOffers(offerRequests); + const { serviceName, version, username } = parsed; 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) + // Delete existing service with same (service_name, version, username) and its related offers (upsert behavior) + const existingService = this.db.prepare(` + SELECT id FROM services + WHERE service_name = ? AND version = ? AND username = ? + `).get(serviceName, version, username) as any; + + if (existingService) { + // Delete related offers first (no FK cascade from offers to services) + this.db.prepare(` + DELETE FROM offers WHERE service_id = ? + `).run(existingService.id); + + // Delete the service + this.db.prepare(` + DELETE FROM services WHERE id = ? + `).run(existingService.id); + } + + // Insert new service with extracted fields + this.db.prepare(` + INSERT INTO services (id, service_fqn, service_name, version, username, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?) - `); - - serviceStmt.run( + `).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, + serviceName, + version, + username, now, request.expiresAt ); - // Touch username to extend expiry - this.touchUsername(request.username); + // Touch username to extend expiry (inline logic) + const expiresAt = now + YEAR_IN_MS; + this.db.prepare(` + UPDATE usernames + SET last_used = ?, expires_at = ? + WHERE username = ? AND expires_at > ? + `).run(now, expiresAt, username, now); }); transaction(); + // Create offers with serviceId (after transaction) + const offerRequests = request.offers.map(offer => ({ + ...offer, + serviceId, + })); + const offers = await this.createOffers(offerRequests); + return { service: { id: serviceId, - username: request.username, serviceFqn: request.serviceFqn, + serviceName, + version, + username, createdAt: now, expiresAt: request.expiresAt, - isPublic: request.isPublic || false, - metadata: request.metadata, }, - indexUuid, offers, }; } - async batchCreateServices(requests: CreateServiceRequest[]): Promise> { - const results = []; + async getOffersForService(serviceId: string): Promise { + const stmt = this.db.prepare(` + SELECT * FROM offers + WHERE service_id = ? AND expires_at > ? + ORDER BY created_at ASC + `); - for (const request of requests) { - const result = await this.createService(request); - results.push(result); - } - - return results; + const rows = stmt.all(serviceId, Date.now()) as any[]; + return rows.map(row => this.rowToOffer(row)); } async getServiceById(serviceId: string): Promise { @@ -525,14 +510,13 @@ export class SQLiteStorage implements Storage { return this.rowToService(row); } - async getServiceByUuid(uuid: string): Promise { + async getServiceByFqn(serviceFqn: 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 > ? + SELECT * FROM services + WHERE service_fqn = ? AND expires_at > ? `); - const row = stmt.get(uuid, Date.now()) as any; + const row = stmt.get(serviceFqn, Date.now()) as any; if (!row) { return null; @@ -541,49 +525,53 @@ export class SQLiteStorage implements Storage { return this.rowToService(row); } - async listServicesForUsername(username: string): Promise { + async discoverServices( + serviceName: string, + version: string, + limit: number, + offset: number + ): Promise { + // Query for unique services with available offers + // We join with offers and filter for available ones (answerer_username IS NULL) 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 > ? + SELECT DISTINCT s.* FROM services s + INNER JOIN offers o ON o.service_id = s.id + WHERE s.service_name = ? + AND s.version = ? + AND s.expires_at > ? + AND o.answerer_username IS NULL + AND o.expires_at > ? ORDER BY s.created_at DESC + LIMIT ? OFFSET ? `); - 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 { - 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 { - 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[]; - + const rows = stmt.all(serviceName, version, Date.now(), Date.now(), limit, offset) as any[]; return rows.map(row => this.rowToService(row)); } + async getRandomService(serviceName: string, version: string): Promise { + // Get a random service with an available offer + const stmt = this.db.prepare(` + SELECT s.* FROM services s + INNER JOIN offers o ON o.service_id = s.id + WHERE s.service_name = ? + AND s.version = ? + AND s.expires_at > ? + AND o.answerer_username IS NULL + AND o.expires_at > ? + ORDER BY RANDOM() + LIMIT 1 + `); + + const row = stmt.get(serviceName, version, Date.now(), Date.now()) as any; + + if (!row) { + return null; + } + + return this.rowToService(row); + } + async deleteService(serviceId: string, username: string): Promise { const stmt = this.db.prepare(` DELETE FROM services @@ -612,14 +600,14 @@ export class SQLiteStorage implements Storage { private rowToOffer(row: any): Offer { return { id: row.id, - peerId: row.peer_id, + username: row.username, serviceId: row.service_id || undefined, + serviceFqn: row.service_fqn || undefined, sdp: row.sdp, createdAt: row.created_at, expiresAt: row.expires_at, lastSeen: row.last_seen, - secret: row.secret || undefined, - answererPeerId: row.answerer_peer_id || undefined, + answererUsername: row.answerer_username || undefined, answerSdp: row.answer_sdp || undefined, answeredAt: row.answered_at || undefined, }; @@ -631,26 +619,12 @@ export class SQLiteStorage implements Storage { private rowToService(row: any): Service { return { id: row.id, - username: row.username, serviceFqn: row.service_fqn, + serviceName: row.service_name, + version: row.version, + username: row.username, 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 { - 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)); - } } diff --git a/src/storage/types.ts b/src/storage/types.ts index dfb546d..c0d82a4 100644 --- a/src/storage/types.ts +++ b/src/storage/types.ts @@ -3,14 +3,14 @@ */ export interface Offer { id: string; - peerId: string; + username: string; serviceId?: string; // Optional link to service (null for standalone offers) + serviceFqn?: string; // Denormalized service FQN for easier queries sdp: string; createdAt: number; expiresAt: number; lastSeen: number; - secret?: string; - answererPeerId?: string; + answererUsername?: string; answerSdp?: string; answeredAt?: number; } @@ -22,7 +22,7 @@ export interface Offer { export interface IceCandidate { id: number; offerId: string; - peerId: string; + username: string; role: 'offerer' | 'answerer'; candidate: any; // Full candidate object as JSON - don't enforce structure createdAt: number; @@ -33,11 +33,11 @@ export interface IceCandidate { */ export interface CreateOfferRequest { id?: string; - peerId: string; + username: string; serviceId?: string; // Optional link to service + serviceFqn?: string; // Optional service FQN sdp: string; expiresAt: number; - secret?: string; } /** @@ -100,11 +100,11 @@ export interface Storage { createOffers(offers: CreateOfferRequest[]): Promise; /** - * Retrieves all offers from a specific peer - * @param peerId Peer identifier - * @returns Array of offers from the peer + * Retrieves all offers from a specific user + * @param username Username identifier + * @returns Array of offers from the user */ - getOffersByPeerId(peerId: string): Promise; + getOffersByUsername(username: string): Promise; /** * Retrieves a specific offer by ID @@ -116,10 +116,10 @@ export interface Storage { /** * Deletes an offer (with ownership verification) * @param offerId Offer identifier - * @param ownerPeerId Peer ID of the owner (for verification) + * @param ownerUsername Username of the owner (for verification) * @returns true if deleted, false if not found or not owned */ - deleteOffer(offerId: string, ownerPeerId: string): Promise; + deleteOffer(offerId: string, ownerUsername: string): Promise; /** * Deletes all expired offers @@ -131,36 +131,35 @@ export interface Storage { /** * Answers an offer (locks it to the answerer) * @param offerId Offer identifier - * @param answererPeerId Answerer's peer ID + * @param answererUsername Answerer's username * @param answerSdp WebRTC answer SDP - * @param secret Optional secret for protected offers * @returns Success status and optional error message */ - answerOffer(offerId: string, answererPeerId: string, answerSdp: string, secret?: string): Promise<{ + answerOffer(offerId: string, answererUsername: string, answerSdp: string): Promise<{ success: boolean; error?: string; }>; /** * Retrieves all answered offers for a specific offerer - * @param offererPeerId Offerer's peer ID + * @param offererUsername Offerer's username * @returns Array of answered offers */ - getAnsweredOffers(offererPeerId: string): Promise; + getAnsweredOffers(offererUsername: string): Promise; // ===== ICE Candidate Management ===== /** * Adds ICE candidates for an offer * @param offerId Offer identifier - * @param peerId Peer ID posting the candidates - * @param role Role of the peer (offerer or answerer) + * @param username Username posting the candidates + * @param role Role of the user (offerer or answerer) * @param candidates Array of candidate objects (stored as plain JSON) * @returns Number of candidates added */ addIceCandidates( offerId: string, - peerId: string, + username: string, role: 'offerer' | 'answerer', candidates: any[] ): Promise;