From 8111cb9cec9678b3efb7ea7f88450f9f4c7b0f70 Mon Sep 17 00:00:00 2001 From: Bas van den Aakster Date: Tue, 9 Dec 2025 22:22:37 +0100 Subject: [PATCH] v0.5.0: Service discovery and FQN format refactoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Changed service FQN format: service:version@username (colon instead of @) - Added service discovery: direct lookup, random selection, paginated queries - Updated parseServiceFqn to handle optional username for discovery - Removed UUID privacy layer (service_index table) - Updated storage interface with discovery methods (discoverServices, getRandomService, getServiceByFqn) - Removed deprecated methods (getServiceByUuid, queryService, listServicesForUsername, findServicesByName, touchUsername, batchCreateServices) - Updated API routes: /services/:fqn with three modes (direct, random, paginated) - Changed offer/answer/ICE routes to offer-specific: /services/:fqn/offers/:offerId/* - Added extracted fields to services table (service_name, version, username) for efficient discovery - Created migration 0007 to update schema and migrate existing data - Added discovery indexes for performance 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- migrations/0007_simplify_schema.sql | 54 ++++ src/app.ts | 412 +++++++++++++--------------- src/crypto.ts | 71 +++-- src/storage/d1.ts | 197 ++++++------- src/storage/types.ts | 109 +++----- 5 files changed, 407 insertions(+), 436 deletions(-) create mode 100644 migrations/0007_simplify_schema.sql diff --git a/migrations/0007_simplify_schema.sql b/migrations/0007_simplify_schema.sql new file mode 100644 index 0000000..f72ad2f --- /dev/null +++ b/migrations/0007_simplify_schema.sql @@ -0,0 +1,54 @@ +-- V0.4.1 Migration: Simplify schema and add service discovery +-- Remove privacy layer (service_index) and add extracted fields for discovery + +-- Step 1: Drop service_index table (privacy layer removal) +DROP TABLE IF EXISTS service_index; + +-- Step 2: Create new services table with extracted fields for discovery +CREATE TABLE services_new ( + id TEXT PRIMARY KEY, + 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, + FOREIGN KEY (username) REFERENCES usernames(username) ON DELETE CASCADE, + UNIQUE(service_fqn) +); + +-- Step 3: Migrate existing data (if any) - parse FQN to extract components +-- Note: This migration assumes FQN format is already "service:version@username" +-- If there's old data with different format, manual intervention may be needed +INSERT INTO services_new (id, service_fqn, service_name, version, username, created_at, expires_at) +SELECT + id, + service_fqn, + -- Extract service_name: everything before first ':' + substr(service_fqn, 1, instr(service_fqn, ':') - 1) as service_name, + -- Extract version: between ':' and '@' + substr( + service_fqn, + instr(service_fqn, ':') + 1, + instr(service_fqn, '@') - instr(service_fqn, ':') - 1 + ) as version, + username, + created_at, + expires_at +FROM services +WHERE service_fqn LIKE '%:%@%'; -- Only migrate properly formatted FQNs + +-- Step 4: Drop old services table +DROP TABLE services; + +-- Step 5: Rename new table to services +ALTER TABLE services_new RENAME TO services; + +-- Step 6: Create indexes for efficient querying +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); + +-- Step 7: Create index on offers for available offer filtering +CREATE INDEX IF NOT EXISTS idx_offers_available ON offers(answerer_peer_id) WHERE answerer_peer_id IS NULL; diff --git a/src/app.ts b/src/app.ts index 4e04899..2dc7dd9 100644 --- a/src/app.ts +++ b/src/app.ts @@ -157,83 +157,133 @@ export function createApp(storage: Storage, config: Config) { } }); + // ===== Service Discovery and Management ===== + /** - * GET /users/:username/services/:fqn - * Get service by username and FQN with semver-compatible matching + * GET /services/:fqn + * Get service by FQN with optional discovery + * Supports three modes: + * 1. Direct lookup: /services/chat:1.0.0@alice - Returns specific user's offer + * 2. Random discovery: /services/chat:1.0.0 - Returns random available offer + * 3. Paginated discovery: /services/chat:1.0.0?limit=10&offset=0 - Returns array of available offers */ - app.get('/users/:username/services/:fqn', async (c) => { + app.get('/services/:fqn', async (c) => { try { - const username = c.req.param('username'); const serviceFqn = decodeURIComponent(c.req.param('fqn')); + const limit = c.req.query('limit'); + const offset = c.req.query('offset'); // Parse the requested FQN const parsed = parseServiceFqn(serviceFqn); if (!parsed) { - return c.json({ error: 'Invalid service FQN format' }, 400); + return c.json({ error: 'Invalid service FQN format. Use service:version or service:version@username' }, 400); } - const { serviceName, version: requestedVersion } = parsed; + const { serviceName, version, username } = parsed; - // Find all services with matching service name - const matchingServices = await storage.findServicesByName(username, serviceName); + // Mode 1: Direct lookup with username + if (username) { + // Find service by exact FQN + const service = await storage.getServiceByFqn(serviceFqn); - if (matchingServices.length === 0) { - return c.json({ error: 'Service not found' }, 404); - } + if (!service) { + return c.json({ error: 'Service not found' }, 404); + } - // Filter to compatible versions - const compatibleServices = matchingServices.filter(service => { - const serviceParsed = parseServiceFqn(service.serviceFqn); - if (!serviceParsed) return false; - return isVersionCompatible(requestedVersion, serviceParsed.version); - }); + // Get available offer from this service + const serviceOffers = await storage.getOffersForService(service.id); + const availableOffer = serviceOffers.find(offer => !offer.answererPeerId); + + if (!availableOffer) { + return c.json({ + error: 'No available offers', + message: 'All offers from this service are currently in use.' + }, 503); + } - if (compatibleServices.length === 0) { return c.json({ - error: 'No compatible version found', - message: `Requested ${serviceFqn}, but no compatible versions available` - }, 404); + serviceId: service.id, + username: service.username, + serviceFqn: service.serviceFqn, + offerId: availableOffer.id, + sdp: availableOffer.sdp, + createdAt: service.createdAt, + expiresAt: service.expiresAt + }, 200); } - // Use the first compatible service (most recently created) - const service = compatibleServices[0]; + // Mode 2 & 3: Discovery without username + if (limit || offset) { + // Paginated discovery + const limitNum = limit ? Math.min(parseInt(limit, 10), 100) : 10; + const offsetNum = offset ? parseInt(offset, 10) : 0; - // Get the UUID for this service - const uuid = await storage.queryService(username, service.serviceFqn); + const services = await storage.discoverServices(serviceName, version, limitNum, offsetNum); - if (!uuid) { - return c.json({ error: 'Service index not found' }, 500); - } + if (services.length === 0) { + return c.json({ + error: 'No services found', + message: `No available services found for ${serviceName}:${version}` + }, 404); + } - // Get all offers for this service - const serviceOffers = await storage.getOffersForService(service.id); + // Get available offers for each service + const servicesWithOffers = await Promise.all( + services.map(async (service) => { + const offers = await storage.getOffersForService(service.id); + const availableOffer = offers.find(offer => !offer.answererPeerId); + return availableOffer ? { + serviceId: service.id, + username: service.username, + serviceFqn: service.serviceFqn, + offerId: availableOffer.id, + sdp: availableOffer.sdp, + createdAt: service.createdAt, + expiresAt: service.expiresAt + } : null; + }) + ); - if (serviceOffers.length === 0) { - return c.json({ error: 'No offers found for this service' }, 404); - } + const availableServices = servicesWithOffers.filter(s => s !== null); - // Find an unanswered offer - const availableOffer = serviceOffers.find(offer => !offer.answererPeerId); - - if (!availableOffer) { return c.json({ - error: 'No available offers', - message: 'All offers from this service are currently in use. Please try again later.' - }, 503); - } + services: availableServices, + count: availableServices.length, + limit: limitNum, + offset: offsetNum + }, 200); + } else { + // Random discovery + const service = await storage.getRandomService(serviceName, version); - return c.json({ - uuid: uuid, - serviceId: service.id, - username: service.username, - serviceFqn: service.serviceFqn, - offerId: availableOffer.id, - sdp: availableOffer.sdp, - isPublic: service.isPublic, - metadata: service.metadata ? JSON.parse(service.metadata) : undefined, - createdAt: service.createdAt, - expiresAt: service.expiresAt - }, 200); + if (!service) { + return c.json({ + error: 'No services found', + message: `No available services found for ${serviceName}:${version}` + }, 404); + } + + // Get available offer + const offers = await storage.getOffersForService(service.id); + const availableOffer = offers.find(offer => !offer.answererPeerId); + + if (!availableOffer) { + return c.json({ + error: 'No available offers', + message: 'Service found but no available offers.' + }, 503); + } + + return c.json({ + serviceId: service.id, + username: service.username, + serviceFqn: service.serviceFqn, + offerId: availableOffer.id, + sdp: availableOffer.sdp, + createdAt: service.createdAt, + expiresAt: service.expiresAt + }, 200); + } } catch (err) { console.error('Error getting service:', err); return c.json({ error: 'Internal server error' }, 500); @@ -241,29 +291,36 @@ export function createApp(storage: Storage, config: Config) { }); /** - * POST /users/:username/services - * Publish a service with one or more offers (RESTful endpoint) + * POST /services + * Publish a service with one or more offers + * Service FQN must include username: service:version@username */ - app.post('/users/:username/services', authMiddleware, async (c) => { + app.post('/services', authMiddleware, async (c) => { let serviceFqn: string | undefined; let createdOffers: any[] = []; try { - const username = c.req.param('username'); const body = await c.req.json(); serviceFqn = body.serviceFqn; - const { offers, ttl, isPublic, metadata, signature, message } = body; + const { offers, ttl, signature, message } = body; if (!serviceFqn || !offers || !Array.isArray(offers) || offers.length === 0) { return c.json({ error: 'Missing required parameters: serviceFqn, offers (must be non-empty array)' }, 400); } - // Validate service FQN + // Validate and parse service FQN const fqnValidation = validateServiceFqn(serviceFqn); if (!fqnValidation.valid) { return c.json({ error: fqnValidation.error }, 400); } + const parsed = parseServiceFqn(serviceFqn); + if (!parsed || !parsed.username) { + return c.json({ error: 'Service FQN must include username (format: service:version@username)' }, 400); + } + + const username = parsed.username; + // Verify username ownership (signature required) if (!signature || !message) { return c.json({ error: 'Missing signature or message for username verification' }, 400); @@ -281,12 +338,9 @@ export function createApp(storage: Storage, config: Config) { } // Delete existing service if one exists (upsert behavior) - const existingUuid = await storage.queryService(username, serviceFqn); - if (existingUuid) { - const existingService = await storage.getServiceByUuid(existingUuid); - if (existingService) { - await storage.deleteService(existingService.id, username); - } + const existingService = await storage.getServiceByFqn(serviceFqn); + if (existingService) { + await storage.deleteService(existingService.id, username); } // Validate all offers @@ -317,11 +371,8 @@ export function createApp(storage: Storage, config: Config) { // Create service with offers const result = await storage.createService({ - username, serviceFqn, expiresAt, - isPublic: isPublic || false, - metadata: metadata ? JSON.stringify(metadata) : undefined, offers: offerRequests }); @@ -329,9 +380,8 @@ export function createApp(storage: Storage, config: Config) { // Return full service details with all offers return c.json({ - uuid: result.indexUuid, - serviceFqn: serviceFqn, - username: username, + serviceFqn: result.service.serviceFqn, + username: result.service.username, serviceId: result.service.id, offers: result.offers.map(o => ({ offerId: o.id, @@ -339,8 +389,6 @@ export function createApp(storage: Storage, config: Config) { createdAt: o.createdAt, expiresAt: o.expiresAt })), - isPublic: result.service.isPublic, - metadata: metadata, createdAt: result.service.createdAt, expiresAt: result.service.expiresAt }, 201); @@ -349,7 +397,6 @@ export function createApp(storage: Storage, config: Config) { console.error('Error details:', { message: (err as Error).message, stack: (err as Error).stack, - username: c.req.param('username'), serviceFqn, offerIds: createdOffers.map(o => o.id) }); @@ -361,21 +408,23 @@ export function createApp(storage: Storage, config: Config) { }); /** - * DELETE /users/:username/services/:fqn - * Delete a service by username and FQN (RESTful) + * DELETE /services/:fqn + * Delete a service by FQN (must include username) */ - app.delete('/users/:username/services/:fqn', authMiddleware, async (c) => { + app.delete('/services/:fqn', authMiddleware, async (c) => { try { - const username = c.req.param('username'); const serviceFqn = decodeURIComponent(c.req.param('fqn')); - // Find service by username and FQN - const uuid = await storage.queryService(username, serviceFqn); - if (!uuid) { - return c.json({ error: 'Service not found' }, 404); + // Parse and validate FQN + const parsed = parseServiceFqn(serviceFqn); + if (!parsed || !parsed.username) { + return c.json({ error: 'Service FQN must include username (format: service:version@username)' }, 400); } - const service = await storage.getServiceByUuid(uuid); + const username = parsed.username; + + // Find service by FQN + const service = await storage.getServiceByFqn(serviceFqn); if (!service) { return c.json({ error: 'Service not found' }, 404); } @@ -393,66 +442,16 @@ export function createApp(storage: Storage, config: Config) { } }); - // ===== Service Management (Legacy - for UUID-based access) ===== + // ===== WebRTC Signaling (Offer-Specific) ===== /** - * GET /services/:uuid - * Get service details by index UUID (kept for privacy) + * POST /services/:fqn/offers/:offerId/answer + * Answer a specific offer from a service */ - app.get('/services/:uuid', async (c) => { + app.post('/services/:fqn/offers/:offerId/answer', authMiddleware, 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 all offers for this service - const serviceOffers = await storage.getOffersForService(service.id); - - if (serviceOffers.length === 0) { - return c.json({ error: 'No offers found for this service' }, 404); - } - - // Find an unanswered offer - const availableOffer = serviceOffers.find(offer => !offer.answererPeerId); - - if (!availableOffer) { - return c.json({ - error: 'No available offers', - message: 'All offers from this service are currently in use. Please try again later.' - }, 503); - } - - return c.json({ - uuid: uuid, - serviceId: service.id, - username: service.username, - serviceFqn: service.serviceFqn, - offerId: availableOffer.id, - sdp: availableOffer.sdp, - isPublic: service.isPublic, - metadata: service.metadata ? JSON.parse(service.metadata) : undefined, - createdAt: service.createdAt, - expiresAt: service.expiresAt - }, 200); - } catch (err) { - console.error('Error getting service:', err); - return c.json({ error: 'Internal server error' }, 500); - } - }); - - // ===== Service-Based WebRTC Signaling ===== - - /** - * POST /services/:uuid/answer - * Answer a service offer - */ - app.post('/services/:uuid/answer', authMiddleware, async (c) => { - try { - const uuid = c.req.param('uuid'); + const serviceFqn = decodeURIComponent(c.req.param('fqn')); + const offerId = c.req.param('offerId'); const body = await c.req.json(); const { sdp } = body; @@ -468,23 +467,15 @@ export function createApp(storage: Storage, config: Config) { return c.json({ error: 'SDP too large (max 64KB)' }, 400); } - // Get the service by UUID - const service = await storage.getServiceByUuid(uuid); - if (!service) { - return c.json({ error: 'Service not found' }, 404); - } - - // Get available offer from service - const serviceOffers = await storage.getOffersForService(service.id); - const availableOffer = serviceOffers.find(offer => !offer.answererPeerId); - - if (!availableOffer) { - return c.json({ error: 'No available offers' }, 503); + // Verify offer exists + const offer = await storage.getOfferById(offerId); + if (!offer) { + return c.json({ error: 'Offer not found' }, 404); } const answererPeerId = getAuthenticatedPeerId(c); - const result = await storage.answerOffer(availableOffer.id, answererPeerId, sdp); + const result = await storage.answerOffer(offerId, answererPeerId, sdp); if (!result.success) { return c.json({ error: result.error }, 400); @@ -492,58 +483,61 @@ export function createApp(storage: Storage, config: Config) { return c.json({ success: true, - offerId: availableOffer.id + offerId: offerId }, 200); } catch (err) { - console.error('Error answering service:', err); + console.error('Error answering offer:', err); return c.json({ error: 'Internal server error' }, 500); } }); /** - * GET /services/:uuid/answer - * Get answer for a service (offerer polls this) + * GET /services/:fqn/offers/:offerId/answer + * Get answer for a specific offer (offerer polls this) */ - app.get('/services/:uuid/answer', authMiddleware, async (c) => { + app.get('/services/:fqn/offers/:offerId/answer', authMiddleware, async (c) => { try { - const uuid = c.req.param('uuid'); + const serviceFqn = decodeURIComponent(c.req.param('fqn')); + const offerId = c.req.param('offerId'); const peerId = getAuthenticatedPeerId(c); - // Get the service by UUID - const service = await storage.getServiceByUuid(uuid); - if (!service) { - return c.json({ error: 'Service not found' }, 404); + // Get the offer + const offer = await storage.getOfferById(offerId); + if (!offer) { + return c.json({ error: 'Offer not found' }, 404); } - // Get offers for this service owned by the requesting peer - const serviceOffers = await storage.getOffersForService(service.id); - const myOffer = serviceOffers.find(offer => offer.peerId === peerId && offer.answererPeerId); + // Verify ownership + if (offer.peerId !== peerId) { + return c.json({ error: 'Not authorized to access this offer' }, 403); + } - if (!myOffer || !myOffer.answerSdp) { + if (!offer.answerSdp) { return c.json({ error: 'Offer not yet answered' }, 404); } return c.json({ - offerId: myOffer.id, - answererId: myOffer.answererPeerId, - sdp: myOffer.answerSdp, - answeredAt: myOffer.answeredAt + offerId: offer.id, + answererId: offer.answererPeerId, + sdp: offer.answerSdp, + answeredAt: offer.answeredAt }, 200); } catch (err) { - console.error('Error getting service answer:', err); + console.error('Error getting offer answer:', err); return c.json({ error: 'Internal server error' }, 500); } }); /** - * POST /services/:uuid/ice-candidates - * Add ICE candidates for a service + * POST /services/:fqn/offers/:offerId/ice-candidates + * Add ICE candidates for a specific offer */ - app.post('/services/:uuid/ice-candidates', authMiddleware, async (c) => { + app.post('/services/:fqn/offers/:offerId/ice-candidates', authMiddleware, async (c) => { try { - const uuid = c.req.param('uuid'); + const serviceFqn = decodeURIComponent(c.req.param('fqn')); + const offerId = c.req.param('offerId'); const body = await c.req.json(); - const { candidates, offerId } = body; + const { candidates } = body; if (!Array.isArray(candidates) || candidates.length === 0) { return c.json({ error: 'Missing or invalid required parameter: candidates' }, 400); @@ -551,75 +545,37 @@ export function createApp(storage: Storage, config: Config) { const peerId = getAuthenticatedPeerId(c); - // Get the service by UUID - const service = await storage.getServiceByUuid(uuid); - if (!service) { - return c.json({ error: 'Service not found' }, 404); - } - - // If offerId is provided, use it; otherwise find the peer's offer - let targetOfferId = offerId; - if (!targetOfferId) { - const serviceOffers = await storage.getOffersForService(service.id); - const myOffer = serviceOffers.find(offer => - offer.peerId === peerId || offer.answererPeerId === peerId - ); - if (!myOffer) { - return c.json({ error: 'No offer found for this peer' }, 404); - } - targetOfferId = myOffer.id; - } - // Get offer to determine role - const offer = await storage.getOfferById(targetOfferId); + const offer = await storage.getOfferById(offerId); if (!offer) { return c.json({ error: 'Offer not found' }, 404); } - // Determine role + // Determine role (offerer or answerer) const role = offer.peerId === peerId ? 'offerer' : 'answerer'; - const count = await storage.addIceCandidates(targetOfferId, peerId, role, candidates); + const count = await storage.addIceCandidates(offerId, peerId, role, candidates); - return c.json({ count, offerId: targetOfferId }, 200); + return c.json({ count, offerId }, 200); } catch (err) { - console.error('Error adding ICE candidates to service:', err); + console.error('Error adding ICE candidates:', err); return c.json({ error: 'Internal server error' }, 500); } }); /** - * GET /services/:uuid/ice-candidates - * Get ICE candidates for a service + * GET /services/:fqn/offers/:offerId/ice-candidates + * Get ICE candidates for a specific offer */ - app.get('/services/:uuid/ice-candidates', authMiddleware, async (c) => { + app.get('/services/:fqn/offers/:offerId/ice-candidates', authMiddleware, async (c) => { try { - const uuid = c.req.param('uuid'); + const serviceFqn = decodeURIComponent(c.req.param('fqn')); + const offerId = c.req.param('offerId'); const since = c.req.query('since'); - const offerId = c.req.query('offerId'); const peerId = getAuthenticatedPeerId(c); - // Get the service by UUID - const service = await storage.getServiceByUuid(uuid); - if (!service) { - return c.json({ error: 'Service not found' }, 404); - } - - // If offerId is provided, use it; otherwise find the peer's offer - let targetOfferId = offerId; - if (!targetOfferId) { - const serviceOffers = await storage.getOffersForService(service.id); - const myOffer = serviceOffers.find(offer => - offer.peerId === peerId || offer.answererPeerId === peerId - ); - if (!myOffer) { - return c.json({ error: 'No offer found for this peer' }, 404); - } - targetOfferId = myOffer.id; - } - // Get offer to determine role - const offer = await storage.getOfferById(targetOfferId); + const offer = await storage.getOfferById(offerId); if (!offer) { return c.json({ error: 'Offer not found' }, 404); } @@ -628,17 +584,17 @@ export function createApp(storage: Storage, config: Config) { const targetRole = offer.peerId === peerId ? 'answerer' : 'offerer'; const sinceTimestamp = since ? parseInt(since, 10) : undefined; - const candidates = await storage.getIceCandidates(targetOfferId, targetRole, sinceTimestamp); + const candidates = await storage.getIceCandidates(offerId, targetRole, sinceTimestamp); return c.json({ candidates: candidates.map(c => ({ candidate: c.candidate, createdAt: c.createdAt })), - offerId: targetOfferId + offerId }, 200); } catch (err) { - console.error('Error getting ICE candidates for service:', 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 8f1a5b8..61298a0 100644 --- a/src/crypto.ts +++ b/src/crypto.ts @@ -192,31 +192,32 @@ export function validateUsername(username: string): { valid: boolean; error?: st } /** - * Validates service FQN format (service-name@version) - * Service name: reverse domain notation (com.example.service) + * Validates service FQN format (service:version@username or service:version) + * Service name: lowercase alphanumeric with dots/dashes (e.g., chat, file-share, com.example.chat) * Version: semantic versioning (1.0.0, 2.1.3-beta, etc.) + * Username: optional, lowercase alphanumeric with dashes */ 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' }; + // Parse the FQN + const parsed = parseServiceFqn(fqn); + if (!parsed) { + return { valid: false, error: 'Service FQN must be in format: service:version[@username]' }; } - const [serviceName, version] = parts; + const { serviceName, version, username } = parsed; - // 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])?)+$/; + // Validate service name (alphanumeric with dots/dashes) + const serviceNameRegex = /^[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)' }; + return { valid: false, error: 'Service name must be lowercase alphanumeric with optional dots/dashes' }; } - if (serviceName.length < 3 || serviceName.length > 128) { - return { valid: false, error: 'Service name must be 3-128 characters' }; + if (serviceName.length < 1 || serviceName.length > 128) { + return { valid: false, error: 'Service name must be 1-128 characters' }; } // Validate version (semantic versioning) @@ -225,6 +226,14 @@ export function validateServiceFqn(fqn: string): { valid: boolean; error?: strin return { valid: false, error: 'Version must be semantic versioning (e.g., 1.0.0, 2.1.3-beta)' }; } + // Validate username if present + if (username) { + const usernameCheck = validateUsername(username); + if (!usernameCheck.valid) { + return usernameCheck; + } + } + return { valid: true }; } @@ -270,15 +279,41 @@ export function isVersionCompatible(requested: string, available: string): boole } /** - * Parse service FQN into service name and version + * Parse service FQN into components + * Formats supported: + * - service:version@username (e.g., "chat:1.0.0@alice") + * - service:version (e.g., "chat:1.0.0") for discovery */ -export function parseServiceFqn(fqn: string): { serviceName: string; version: string } | null { - const parts = fqn.split('@'); - if (parts.length !== 2) return null; +export function parseServiceFqn(fqn: string): { serviceName: string; version: string; username: string | null } | null { + if (!fqn || typeof fqn !== 'string') return null; + + // Check if username is present + const atIndex = fqn.lastIndexOf('@'); + let serviceVersion: string; + let username: string | null = null; + + if (atIndex > 0) { + // Format: service:version@username + serviceVersion = fqn.substring(0, atIndex); + username = fqn.substring(atIndex + 1); + } else { + // Format: service:version (no username) + serviceVersion = fqn; + } + + // Split service:version + const colonIndex = serviceVersion.indexOf(':'); + if (colonIndex <= 0) return null; // No colon or colon at start + + const serviceName = serviceVersion.substring(0, colonIndex); + const version = serviceVersion.substring(colonIndex + 1); + + if (!serviceName || !version) return null; return { - serviceName: parts[0], - version: parts[1], + serviceName, + version, + username, }; } diff --git a/src/storage/d1.ts b/src/storage/d1.ts index d1a99a0..99f1f72 100644 --- a/src/storage/d1.ts +++ b/src/storage/d1.ts @@ -8,9 +8,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 @@ -84,36 +84,23 @@ export class D1Storage 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 + -- 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); `); } @@ -382,18 +369,6 @@ export class D1Storage implements Storage { }; } - 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(` @@ -407,36 +382,32 @@ export class D1Storage implements Storage { async createService(request: CreateServiceRequest): Promise<{ service: Service; - indexUuid: string; offers: Offer[]; }> { const serviceId = crypto.randomUUID(); - const indexUuid = crypto.randomUUID(); const now = Date.now(); - // Insert service + // 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 { serviceName, version, username } = parsed; + + // Insert service with extracted fields await this.db.prepare(` - INSERT INTO services (id, username, service_fqn, created_at, expires_at, is_public, metadata) + INSERT INTO services (id, service_fqn, service_name, version, username, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?) `).bind( serviceId, - request.username, - request.serviceFqn, - now, - request.expiresAt, - request.isPublic ? 1 : 0, - request.metadata || null - ).run(); - - // 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, + serviceName, + version, + username, now, request.expiresAt ).run(); @@ -448,36 +419,28 @@ export class D1Storage implements Storage { })); const offers = await this.createOffers(offerRequests); - // Touch username to extend expiry - await this.touchUsername(request.username); + // Touch username to extend expiry (inline logic) + const expiresAt = now + YEAR_IN_MS; + await this.db.prepare(` + UPDATE usernames + SET last_used = ?, expires_at = ? + WHERE username = ? AND expires_at > ? + `).bind(now, expiresAt, username, now).run(); 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 = []; - for (const request of requests) { - const result = await this.createService(request); - results.push(result); - } - return results; - } async getOffersForService(serviceId: string): Promise { const result = await this.db.prepare(` @@ -506,12 +469,11 @@ export class D1Storage implements Storage { return this.rowToService(result as any); } - async getServiceByUuid(uuid: string): Promise { + async getServiceByFqn(serviceFqn: 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(); + SELECT * FROM services + WHERE service_fqn = ? AND expires_at > ? + `).bind(serviceFqn, Date.now()).first(); if (!result) { return null; @@ -520,43 +482,29 @@ export class D1Storage implements Storage { return this.rowToService(result as any); } - 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_peer_id IS NULL) 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 > ? + 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.expires_at > ? ORDER BY s.created_at DESC - `).bind(username, Date.now()).all(); - - if (!result.results) { - return []; - } - - return result.results.map((row: any) => ({ - uuid: row.uuid, - isPublic: row.is_public === 1, - serviceFqn: row.is_public === 1 ? row.service_fqn : undefined, - metadata: row.is_public === 1 ? row.metadata || undefined : undefined, - })); - } - - async queryService(username: string, serviceFqn: string): Promise { - const result = await this.db.prepare(` - SELECT si.uuid FROM service_index si - INNER JOIN services s ON si.service_id = s.id - WHERE si.username = ? AND si.service_fqn = ? AND si.expires_at > ? - `).bind(username, serviceFqn, Date.now()).first(); - - return result ? (result as any).uuid : null; - } - - async findServicesByName(username: string, serviceName: string): Promise { - const result = await this.db.prepare(` - SELECT * FROM services - WHERE username = ? AND service_fqn LIKE ? AND expires_at > ? - ORDER BY created_at DESC - `).bind(username, `${serviceName}@%`, Date.now()).all(); + LIMIT ? OFFSET ? + `).bind(serviceName, version, Date.now(), Date.now(), limit, offset).all(); if (!result.results) { return []; @@ -565,6 +513,27 @@ export class D1Storage implements Storage { return result.results.map(row => this.rowToService(row as any)); } + async getRandomService(serviceName: string, version: string): Promise { + // Get a random service with an available offer + const result = await 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_peer_id IS NULL + AND o.expires_at > ? + ORDER BY RANDOM() + LIMIT 1 + `).bind(serviceName, version, Date.now(), Date.now()).first(); + + if (!result) { + return null; + } + + return this.rowToService(result as any); + } + async deleteService(serviceId: string, username: string): Promise { const result = await this.db.prepare(` DELETE FROM services @@ -613,12 +582,12 @@ export class D1Storage 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, }; } } diff --git a/src/storage/types.ts b/src/storage/types.ts index b2d31f2..dfb546d 100644 --- a/src/storage/types.ts +++ b/src/storage/types.ts @@ -64,58 +64,27 @@ export interface ClaimUsernameRequest { /** * Represents a published service (can have multiple offers) + * New format: service:version@username (e.g., chat:1.0.0@alice) */ export interface Service { id: string; // UUID v4 - username: string; - serviceFqn: string; // com.example.chat@1.0.0 + serviceFqn: string; // Full FQN: chat:1.0.0@alice + serviceName: string; // Extracted: chat + version: string; // Extracted: 1.0.0 + username: string; // Extracted: alice createdAt: number; expiresAt: number; - isPublic: boolean; - metadata?: string; // JSON service description } /** * Request to create a single service */ export interface CreateServiceRequest { - username: string; - serviceFqn: string; + serviceFqn: string; // Full FQN with username: chat:1.0.0@alice expiresAt: number; - isPublic?: boolean; - metadata?: string; offers: CreateOfferRequest[]; // Multiple offers per service } -/** - * Request to create multiple services in batch - */ -export interface BatchCreateServicesRequest { - services: CreateServiceRequest[]; -} - -/** - * Represents a service index entry (privacy layer) - */ -export interface ServiceIndex { - uuid: string; // Random UUID for privacy - serviceId: string; - username: string; - serviceFqn: string; - createdAt: number; - expiresAt: number; -} - -/** - * Service info for discovery (privacy-aware) - */ -export interface ServiceInfo { - uuid: string; - isPublic: boolean; - serviceFqn?: string; // Only present if public - metadata?: string; // Only present if public -} - /** * Storage interface for rondevu DNS-like system * Implementations can use different backends (SQLite, D1, etc.) @@ -225,13 +194,6 @@ export interface Storage { */ 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 @@ -244,24 +206,13 @@ export interface Storage { /** * Creates a new service with offers * @param request Service creation request (includes offers) - * @returns Created service with generated ID, index UUID, and created offers + * @returns Created service with generated ID and created offers */ createService(request: CreateServiceRequest): Promise<{ service: Service; - indexUuid: string; offers: Offer[]; }>; - /** - * Creates multiple services with offers in batch - * @param requests Array of service creation requests - * @returns Array of created services with IDs, UUIDs, and offers - */ - batchCreateServices(requests: CreateServiceRequest[]): Promise>; /** * Gets all offers for a service @@ -278,34 +229,40 @@ export interface Storage { getServiceById(serviceId: string): Promise; /** - * Gets a service by its index UUID - * @param uuid Index UUID + * Gets a service by its fully qualified name (FQN) + * @param serviceFqn Full service FQN (e.g., "chat:1.0.0@alice") * @returns Service if found, null otherwise */ - getServiceByUuid(uuid: string): Promise; + getServiceByFqn(serviceFqn: 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) + * Discovers services by name and version with pagination + * Returns unique available offers (where answerer_peer_id IS NULL) + * @param serviceName Service name (e.g., 'chat') + * @param version Version string for semver matching (e.g., '1.0.0') + * @param limit Maximum number of unique services to return + * @param offset Number of services to skip + * @returns Array of services with available offers */ - listServicesForUsername(username: string): Promise; + discoverServices( + serviceName: string, + version: string, + limit: number, + offset: number + ): Promise; /** - * Queries a service by username and FQN - * @param username Username - * @param serviceFqn Service FQN - * @returns Service index UUID if found, null otherwise + * Gets a random available service by name and version + * Returns a single random offer that is available (answerer_peer_id IS NULL) + * @param serviceName Service name (e.g., 'chat') + * @param version Version string for semver matching (e.g., '1.0.0') + * @returns Random service with available offer, or null if none found */ - queryService(username: string, serviceFqn: string): Promise; - - /** - * Finds all services by username and service name (without version) - * @param username Username - * @param serviceName Service name (e.g., 'com.example.chat') - * @returns Array of services with matching service name - */ - findServicesByName(username: string, serviceName: string): Promise; + getRandomService(serviceName: string, version: string): Promise; /** * Deletes a service (with ownership verification)