mirror of
https://github.com/xtr-dev/rondevu-server.git
synced 2025-12-10 02:43:24 +00:00
feat: Add semver-compatible service discovery with privacy
## Breaking Changes
### Removed Endpoints
- Removed GET /users/:username/services (service listing)
- Services are now completely hidden - cannot be enumerated
### Updated Endpoints
- GET /users/:username/services/:fqn now supports semver matching
- Requesting chat@1.0.0 will match chat@1.2.3, chat@1.5.0, etc.
- Will NOT match chat@2.0.0 (different major version)
## New Features
### Semantic Versioning Support
- Compatible version matching following semver rules (^1.0.0)
- Major version must match exactly
- For major version 0, minor must also match (0.x.y is unstable)
- Available version must be >= requested version
- Prerelease versions require exact match
### Privacy Improvements
- All services are now hidden by default
- No way to enumerate or list services for a username
- Must know exact service name to discover
## Implementation
### Server (src/)
- crypto.ts: Added parseVersion(), isVersionCompatible(), parseServiceFqn()
- storage/types.ts: Added findServicesByName() interface method
- storage/sqlite.ts: Implemented findServicesByName() with LIKE query
- storage/d1.ts: Implemented findServicesByName() with LIKE query
- app.ts: Updated GET /:username/services/:fqn with semver matching
### Semver Matching Logic
- Parse requested version: chat@1.0.0 → {name: "chat", version: "1.0.0"}
- Find all services with matching name: chat@*
- Filter to compatible versions using semver rules
- Return first match (most recently created)
## Examples
Request: chat@1.0.0
Matches: chat@1.0.0, chat@1.2.3, chat@1.9.5
Does NOT match: chat@0.9.0, chat@2.0.0, chat@1.0.0-beta
🤖 Generated with Claude Code
This commit is contained in:
62
src/app.ts
62
src/app.ts
@@ -3,7 +3,7 @@ import { cors } from 'hono/cors';
|
|||||||
import { Storage } from './storage/types.ts';
|
import { Storage } from './storage/types.ts';
|
||||||
import { Config } from './config.ts';
|
import { Config } from './config.ts';
|
||||||
import { createAuthMiddleware, getAuthenticatedPeerId } from './middleware/auth.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';
|
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 /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) => {
|
app.get('/users/:username/services/:fqn', async (c) => {
|
||||||
try {
|
try {
|
||||||
const username = c.req.param('username');
|
const username = c.req.param('username');
|
||||||
const serviceFqn = decodeURIComponent(c.req.param('fqn'));
|
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);
|
return c.json({ error: 'Service not found' }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get full service details
|
// Filter to compatible versions
|
||||||
const service = await storage.getServiceByUuid(uuid);
|
const compatibleServices = matchingServices.filter(service => {
|
||||||
|
const serviceParsed = parseServiceFqn(service.serviceFqn);
|
||||||
|
if (!serviceParsed) return false;
|
||||||
|
return isVersionCompatible(requestedVersion, serviceParsed.version);
|
||||||
|
});
|
||||||
|
|
||||||
if (!service) {
|
if (compatibleServices.length === 0) {
|
||||||
return c.json({ error: 'Service not found' }, 404);
|
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
|
// Get all offers for this service
|
||||||
|
|||||||
@@ -228,6 +228,60 @@ export function validateServiceFqn(fqn: string): { valid: boolean; error?: strin
|
|||||||
return { valid: true };
|
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)
|
* Validates timestamp is within acceptable range (prevents replay attacks)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -544,6 +544,20 @@ export class D1Storage implements Storage {
|
|||||||
return result ? (result as any).uuid : null;
|
return result ? (result as any).uuid : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findServicesByName(username: string, serviceName: string): Promise<Service[]> {
|
||||||
|
const result = await this.db.prepare(`
|
||||||
|
SELECT * FROM services
|
||||||
|
WHERE username = ? AND service_fqn LIKE ? AND expires_at > ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`).bind(username, `${serviceName}@%`, Date.now()).all();
|
||||||
|
|
||||||
|
if (!result.results) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.results.map(row => this.rowToService(row as any));
|
||||||
|
}
|
||||||
|
|
||||||
async deleteService(serviceId: string, username: string): Promise<boolean> {
|
async deleteService(serviceId: string, username: string): Promise<boolean> {
|
||||||
const result = await this.db.prepare(`
|
const result = await this.db.prepare(`
|
||||||
DELETE FROM services
|
DELETE FROM services
|
||||||
|
|||||||
@@ -572,6 +572,18 @@ export class SQLiteStorage implements Storage {
|
|||||||
return row ? row.uuid : null;
|
return row ? row.uuid : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findServicesByName(username: string, serviceName: string): Promise<Service[]> {
|
||||||
|
const stmt = this.db.prepare(`
|
||||||
|
SELECT * FROM services
|
||||||
|
WHERE username = ? AND service_fqn LIKE ? AND expires_at > ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`);
|
||||||
|
|
||||||
|
const rows = stmt.all(username, `${serviceName}@%`, Date.now()) as any[];
|
||||||
|
|
||||||
|
return rows.map(row => this.rowToService(row));
|
||||||
|
}
|
||||||
|
|
||||||
async deleteService(serviceId: string, username: string): Promise<boolean> {
|
async deleteService(serviceId: string, username: string): Promise<boolean> {
|
||||||
const stmt = this.db.prepare(`
|
const stmt = this.db.prepare(`
|
||||||
DELETE FROM services
|
DELETE FROM services
|
||||||
|
|||||||
@@ -299,6 +299,14 @@ export interface Storage {
|
|||||||
*/
|
*/
|
||||||
queryService(username: string, serviceFqn: string): Promise<string | null>;
|
queryService(username: string, serviceFqn: string): Promise<string | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds all services by username and service name (without version)
|
||||||
|
* @param username Username
|
||||||
|
* @param serviceName Service name (e.g., 'com.example.chat')
|
||||||
|
* @returns Array of services with matching service name
|
||||||
|
*/
|
||||||
|
findServicesByName(username: string, serviceName: string): Promise<Service[]>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes a service (with ownership verification)
|
* Deletes a service (with ownership verification)
|
||||||
* @param serviceId Service ID
|
* @param serviceId Service ID
|
||||||
|
|||||||
Reference in New Issue
Block a user