From 5c71f66a26ea4a6f1e2a2f36e15d13f8de2ea78e Mon Sep 17 00:00:00 2001 From: Bas van den Aakster Date: Sun, 7 Dec 2025 21:56:19 +0100 Subject: [PATCH] feat: Add semver-compatible service discovery with privacy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Breaking Changes ### Removed Endpoints - Removed GET /users/:username/services (service listing) - Services are now completely hidden - cannot be enumerated ### Updated Endpoints - GET /users/:username/services/:fqn now supports semver matching - Requesting chat@1.0.0 will match chat@1.2.3, chat@1.5.0, etc. - Will NOT match chat@2.0.0 (different major version) ## New Features ### Semantic Versioning Support - Compatible version matching following semver rules (^1.0.0) - Major version must match exactly - For major version 0, minor must also match (0.x.y is unstable) - Available version must be >= requested version - Prerelease versions require exact match ### Privacy Improvements - All services are now hidden by default - No way to enumerate or list services for a username - Must know exact service name to discover ## Implementation ### Server (src/) - crypto.ts: Added parseVersion(), isVersionCompatible(), parseServiceFqn() - storage/types.ts: Added findServicesByName() interface method - storage/sqlite.ts: Implemented findServicesByName() with LIKE query - storage/d1.ts: Implemented findServicesByName() with LIKE query - app.ts: Updated GET /:username/services/:fqn with semver matching ### Semver Matching Logic - Parse requested version: chat@1.0.0 → {name: "chat", version: "1.0.0"} - Find all services with matching name: chat@* - Filter to compatible versions using semver rules - Return first match (most recently created) ## Examples Request: chat@1.0.0 Matches: chat@1.0.0, chat@1.2.3, chat@1.9.5 Does NOT match: chat@0.9.0, chat@2.0.0, chat@1.0.0-beta 🤖 Generated with Claude Code --- src/app.ts | 62 ++++++++++++++++++++++++------------------- src/crypto.ts | 54 +++++++++++++++++++++++++++++++++++++ src/storage/d1.ts | 14 ++++++++++ src/storage/sqlite.ts | 12 +++++++++ src/storage/types.ts | 8 ++++++ 5 files changed, 122 insertions(+), 28 deletions(-) diff --git a/src/app.ts b/src/app.ts index bfa1c52..0a2c32f 100644 --- a/src/app.ts +++ b/src/app.ts @@ -3,7 +3,7 @@ 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 } from './crypto.ts'; +import { generatePeerId, encryptPeerId, validateUsernameClaim, validateServicePublish, validateServiceFqn, parseServiceFqn, isVersionCompatible } from './crypto.ts'; import type { Context } from 'hono'; /** @@ -157,46 +157,52 @@ export function createApp(storage: Storage, config: Config) { } }); - /** - * GET /users/:username/services - * List services for a username (privacy-preserving) - */ - app.get('/users/:username/services', async (c) => { - try { - const username = c.req.param('username'); - - const services = await storage.listServicesForUsername(username); - - return c.json({ - username, - services - }, 200); - } catch (err) { - console.error('Error listing services:', err); - return c.json({ error: 'Internal server error' }, 500); - } - }); - /** * GET /users/:username/services/:fqn - * Get service by username and FQN (replaces POST query endpoint) + * Get service by username and FQN with semver-compatible matching */ app.get('/users/:username/services/:fqn', async (c) => { try { const username = c.req.param('username'); const serviceFqn = decodeURIComponent(c.req.param('fqn')); - const uuid = await storage.queryService(username, serviceFqn); + // Parse the requested FQN + const parsed = parseServiceFqn(serviceFqn); + if (!parsed) { + return c.json({ error: 'Invalid service FQN format' }, 400); + } - if (!uuid) { + const { serviceName, version: requestedVersion } = parsed; + + // Find all services with matching service name + const matchingServices = await storage.findServicesByName(username, serviceName); + + if (matchingServices.length === 0) { return c.json({ error: 'Service not found' }, 404); } - // Get full service details - const service = await storage.getServiceByUuid(uuid); + // Filter to compatible versions + const compatibleServices = matchingServices.filter(service => { + const serviceParsed = parseServiceFqn(service.serviceFqn); + if (!serviceParsed) return false; + return isVersionCompatible(requestedVersion, serviceParsed.version); + }); - if (!service) { - return c.json({ error: 'Service not found' }, 404); + if (compatibleServices.length === 0) { + return c.json({ + error: 'No compatible version found', + message: `Requested ${serviceFqn}, but no compatible versions available` + }, 404); + } + + // Use the first compatible service (most recently created) + const service = compatibleServices[0]; + + // Get the UUID for this service + const uuid = await storage.queryService(username, service.serviceFqn); + + if (!uuid) { + return c.json({ error: 'Service index not found' }, 500); } // Get all offers for this service diff --git a/src/crypto.ts b/src/crypto.ts index 023d110..8f1a5b8 100644 --- a/src/crypto.ts +++ b/src/crypto.ts @@ -228,6 +228,60 @@ export function validateServiceFqn(fqn: string): { valid: boolean; error?: strin return { valid: true }; } +/** + * Parse semantic version string into components + */ +export function parseVersion(version: string): { major: number; minor: number; patch: number; prerelease?: string } | null { + const match = version.match(/^([0-9]+)\.([0-9]+)\.([0-9]+)(-[a-z0-9.-]+)?$/); + if (!match) return null; + + return { + major: parseInt(match[1], 10), + minor: parseInt(match[2], 10), + patch: parseInt(match[3], 10), + prerelease: match[4]?.substring(1), // Remove leading dash + }; +} + +/** + * Check if two versions are compatible (same major version) + * Following semver rules: ^1.0.0 matches 1.x.x but not 2.x.x + */ +export function isVersionCompatible(requested: string, available: string): boolean { + const req = parseVersion(requested); + const avail = parseVersion(available); + + if (!req || !avail) return false; + + // Major version must match + if (req.major !== avail.major) return false; + + // If major is 0, minor must also match (0.x.y is unstable) + if (req.major === 0 && req.minor !== avail.minor) return false; + + // Available version must be >= requested version + if (avail.minor < req.minor) return false; + if (avail.minor === req.minor && avail.patch < req.patch) return false; + + // Prerelease versions are only compatible with exact matches + if (req.prerelease && req.prerelease !== avail.prerelease) return false; + + return true; +} + +/** + * Parse service FQN into service name and version + */ +export function parseServiceFqn(fqn: string): { serviceName: string; version: string } | null { + const parts = fqn.split('@'); + if (parts.length !== 2) return null; + + return { + serviceName: parts[0], + version: parts[1], + }; +} + /** * Validates timestamp is within acceptable range (prevents replay attacks) */ diff --git a/src/storage/d1.ts b/src/storage/d1.ts index 99403c5..57aeca4 100644 --- a/src/storage/d1.ts +++ b/src/storage/d1.ts @@ -544,6 +544,20 @@ export class D1Storage implements Storage { 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(); + + if (!result.results) { + return []; + } + + return result.results.map(row => this.rowToService(row as any)); + } + async deleteService(serviceId: string, username: string): Promise { const result = await this.db.prepare(` DELETE FROM services diff --git a/src/storage/sqlite.ts b/src/storage/sqlite.ts index 058037e..c39cc33 100644 --- a/src/storage/sqlite.ts +++ b/src/storage/sqlite.ts @@ -572,6 +572,18 @@ export class SQLiteStorage implements Storage { 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[]; + + return rows.map(row => this.rowToService(row)); + } + async deleteService(serviceId: string, username: string): Promise { const stmt = this.db.prepare(` DELETE FROM services diff --git a/src/storage/types.ts b/src/storage/types.ts index 086ae62..b2d31f2 100644 --- a/src/storage/types.ts +++ b/src/storage/types.ts @@ -299,6 +299,14 @@ export interface Storage { */ 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; + /** * Deletes a service (with ownership verification) * @param serviceId Service ID