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:
2025-12-07 21:56:19 +01:00
parent ca3db47009
commit 5c71f66a26
5 changed files with 122 additions and 28 deletions

View File

@@ -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