mirror of
https://github.com/xtr-dev/rondevu-server.git
synced 2025-12-14 04:43:24 +00:00
refactor: Convert to RPC interface with single /rpc endpoint
BREAKING CHANGES: - Replaced REST API with RPC interface - Single POST /rpc endpoint for all operations - Removed auth middleware (per-method auth instead) - Support for batch operations - Message format changed for all methods Changes: - Created src/rpc.ts with all method handlers - Simplified src/app.ts to only handle /rpc endpoint - Removed src/middleware/auth.ts - Updated README.md with complete RPC documentation
This commit is contained in:
721
src/rpc.ts
Normal file
721
src/rpc.ts
Normal file
@@ -0,0 +1,721 @@
|
||||
import { Context } from 'hono';
|
||||
import { Storage } from './storage/types.ts';
|
||||
import { Config } from './config.ts';
|
||||
import {
|
||||
validateUsernameClaim,
|
||||
validateServicePublish,
|
||||
validateServiceFqn,
|
||||
parseServiceFqn,
|
||||
isVersionCompatible,
|
||||
verifyEd25519Signature,
|
||||
validateAuthMessage,
|
||||
} from './crypto.ts';
|
||||
|
||||
/**
|
||||
* RPC request format
|
||||
*/
|
||||
export interface RpcRequest {
|
||||
method: string;
|
||||
message: string;
|
||||
signature: string;
|
||||
params?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* RPC response format
|
||||
*/
|
||||
export interface RpcResponse {
|
||||
success: boolean;
|
||||
result?: any;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* RPC method handler
|
||||
*/
|
||||
type RpcHandler = (
|
||||
params: any,
|
||||
message: string,
|
||||
signature: string,
|
||||
storage: Storage,
|
||||
config: Config
|
||||
) => Promise<any>;
|
||||
|
||||
/**
|
||||
* Verify authentication for a method call
|
||||
*/
|
||||
async function verifyAuth(
|
||||
username: string,
|
||||
message: string,
|
||||
signature: string,
|
||||
storage: Storage
|
||||
): Promise<{ valid: boolean; error?: string }> {
|
||||
// Get username record to fetch public key
|
||||
const usernameRecord = await storage.getUsername(username);
|
||||
if (!usernameRecord) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Username "${username}" is not claimed. Please claim username first.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Verify Ed25519 signature
|
||||
const isValid = await verifyEd25519Signature(
|
||||
usernameRecord.publicKey,
|
||||
signature,
|
||||
message
|
||||
);
|
||||
if (!isValid) {
|
||||
return { valid: false, error: 'Invalid signature' };
|
||||
}
|
||||
|
||||
// Validate message format and timestamp
|
||||
const validation = validateAuthMessage(username, message);
|
||||
if (!validation.valid) {
|
||||
return { valid: false, error: validation.error };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract username from message
|
||||
*/
|
||||
function extractUsername(message: string): string | null {
|
||||
// Message format: method:username:...
|
||||
const parts = message.split(':');
|
||||
if (parts.length < 2) return null;
|
||||
return parts[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* RPC Method Handlers
|
||||
*/
|
||||
|
||||
const handlers: Record<string, RpcHandler> = {
|
||||
/**
|
||||
* Check if username is available
|
||||
*/
|
||||
async getUser(params, message, signature, storage, config) {
|
||||
const { username } = params;
|
||||
const claimed = await storage.getUsername(username);
|
||||
|
||||
if (!claimed) {
|
||||
return {
|
||||
username,
|
||||
available: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
username: claimed.username,
|
||||
available: false,
|
||||
claimedAt: claimed.claimedAt,
|
||||
expiresAt: claimed.expiresAt,
|
||||
publicKey: claimed.publicKey,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Claim a username
|
||||
*/
|
||||
async claimUsername(params, message, signature, storage, config) {
|
||||
const { username, publicKey } = params;
|
||||
|
||||
// Validate claim
|
||||
const validation = await validateUsernameClaim({
|
||||
username,
|
||||
publicKey,
|
||||
signature,
|
||||
message,
|
||||
});
|
||||
|
||||
if (!validation.valid) {
|
||||
throw new Error(validation.error || 'Invalid username claim');
|
||||
}
|
||||
|
||||
// Claim the username
|
||||
const expiresAt = Date.now() + 365 * 24 * 60 * 60 * 1000; // 365 days
|
||||
await storage.claimUsername({
|
||||
username,
|
||||
publicKey,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
return { success: true, username };
|
||||
},
|
||||
|
||||
/**
|
||||
* Get service by FQN
|
||||
*/
|
||||
async getService(params, message, signature, storage, config) {
|
||||
const { serviceFqn, limit, offset } = params;
|
||||
const username = extractUsername(message);
|
||||
|
||||
// Verify authentication
|
||||
if (username) {
|
||||
const auth = await verifyAuth(username, message, signature, storage);
|
||||
if (!auth.valid) {
|
||||
throw new Error(auth.error);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse and validate FQN
|
||||
const fqnValidation = validateServiceFqn(serviceFqn);
|
||||
if (!fqnValidation.valid) {
|
||||
throw new Error(fqnValidation.error || 'Invalid service FQN');
|
||||
}
|
||||
|
||||
const parsed = parseServiceFqn(serviceFqn);
|
||||
if (!parsed) {
|
||||
throw new Error('Failed to parse service FQN');
|
||||
}
|
||||
|
||||
// Paginated discovery mode
|
||||
if (limit !== undefined) {
|
||||
const pageLimit = Math.min(Math.max(1, limit), 100);
|
||||
const pageOffset = Math.max(0, offset || 0);
|
||||
|
||||
const allServices = await storage.getServicesByName(
|
||||
parsed.service,
|
||||
parsed.version
|
||||
);
|
||||
const compatibleServices = allServices.filter((s) => {
|
||||
const serviceVersion = parseServiceFqn(s.serviceFqn);
|
||||
return (
|
||||
serviceVersion &&
|
||||
isVersionCompatible(parsed.version, serviceVersion.version)
|
||||
);
|
||||
});
|
||||
|
||||
const usernameSet = new Set<string>();
|
||||
const uniqueServices: any[] = [];
|
||||
|
||||
for (const service of compatibleServices) {
|
||||
if (!usernameSet.has(service.username)) {
|
||||
usernameSet.add(service.username);
|
||||
const offers = await storage.getOffersByService(service.id);
|
||||
const availableOffer = offers.find((o) => !o.answererUsername);
|
||||
|
||||
if (availableOffer) {
|
||||
uniqueServices.push({
|
||||
serviceId: service.id,
|
||||
username: service.username,
|
||||
serviceFqn: service.serviceFqn,
|
||||
offerId: availableOffer.id,
|
||||
sdp: availableOffer.sdp,
|
||||
createdAt: service.createdAt,
|
||||
expiresAt: service.expiresAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const paginatedServices = uniqueServices.slice(
|
||||
pageOffset,
|
||||
pageOffset + pageLimit
|
||||
);
|
||||
|
||||
return {
|
||||
services: paginatedServices,
|
||||
count: paginatedServices.length,
|
||||
limit: pageLimit,
|
||||
offset: pageOffset,
|
||||
};
|
||||
}
|
||||
|
||||
// Direct lookup with username
|
||||
if (parsed.username) {
|
||||
const service = await storage.getServiceByFqn(serviceFqn);
|
||||
if (!service) {
|
||||
throw new Error('Service not found');
|
||||
}
|
||||
|
||||
const offers = await storage.getOffersByService(service.id);
|
||||
const availableOffer = offers.find((o) => !o.answererUsername);
|
||||
|
||||
if (!availableOffer) {
|
||||
throw new Error('Service has no available offers');
|
||||
}
|
||||
|
||||
return {
|
||||
serviceId: service.id,
|
||||
username: service.username,
|
||||
serviceFqn: service.serviceFqn,
|
||||
offerId: availableOffer.id,
|
||||
sdp: availableOffer.sdp,
|
||||
createdAt: service.createdAt,
|
||||
expiresAt: service.expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
// Random discovery without username
|
||||
const allServices = await storage.getServicesByName(
|
||||
parsed.service,
|
||||
parsed.version
|
||||
);
|
||||
const compatibleServices = allServices.filter((s) => {
|
||||
const serviceVersion = parseServiceFqn(s.serviceFqn);
|
||||
return (
|
||||
serviceVersion &&
|
||||
isVersionCompatible(parsed.version, serviceVersion.version)
|
||||
);
|
||||
});
|
||||
|
||||
if (compatibleServices.length === 0) {
|
||||
throw new Error('No services found');
|
||||
}
|
||||
|
||||
const randomService =
|
||||
compatibleServices[
|
||||
Math.floor(Math.random() * compatibleServices.length)
|
||||
];
|
||||
const offers = await storage.getOffersByService(randomService.id);
|
||||
const availableOffer = offers.find((o) => !o.answererUsername);
|
||||
|
||||
if (!availableOffer) {
|
||||
throw new Error('Service has no available offers');
|
||||
}
|
||||
|
||||
return {
|
||||
serviceId: randomService.id,
|
||||
username: randomService.username,
|
||||
serviceFqn: randomService.serviceFqn,
|
||||
offerId: availableOffer.id,
|
||||
sdp: availableOffer.sdp,
|
||||
createdAt: randomService.createdAt,
|
||||
expiresAt: randomService.expiresAt,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Publish a service
|
||||
*/
|
||||
async publishService(params, message, signature, storage, config) {
|
||||
const { serviceFqn, offers, ttl } = params;
|
||||
const username = extractUsername(message);
|
||||
|
||||
if (!username) {
|
||||
throw new Error('Username required for service publishing');
|
||||
}
|
||||
|
||||
// Verify authentication
|
||||
const auth = await verifyAuth(username, message, signature, storage);
|
||||
if (!auth.valid) {
|
||||
throw new Error(auth.error);
|
||||
}
|
||||
|
||||
// Validate service FQN
|
||||
const fqnValidation = validateServiceFqn(serviceFqn);
|
||||
if (!fqnValidation.valid) {
|
||||
throw new Error(fqnValidation.error || 'Invalid service FQN');
|
||||
}
|
||||
|
||||
const parsed = parseServiceFqn(serviceFqn);
|
||||
if (!parsed || !parsed.username) {
|
||||
throw new Error('Service FQN must include username');
|
||||
}
|
||||
|
||||
if (parsed.username !== username) {
|
||||
throw new Error('Service FQN username must match authenticated username');
|
||||
}
|
||||
|
||||
// Validate offers
|
||||
if (!offers || !Array.isArray(offers) || offers.length === 0) {
|
||||
throw new Error('Must provide at least one offer');
|
||||
}
|
||||
|
||||
if (offers.length > config.maxOffersPerRequest) {
|
||||
throw new Error(
|
||||
`Too many offers (max ${config.maxOffersPerRequest})`
|
||||
);
|
||||
}
|
||||
|
||||
// Create service
|
||||
const now = Date.now();
|
||||
const offerTtl =
|
||||
ttl !== undefined
|
||||
? Math.min(
|
||||
Math.max(ttl, config.offerMinTtl),
|
||||
config.offerMaxTtl
|
||||
)
|
||||
: config.offerDefaultTtl;
|
||||
const expiresAt = now + offerTtl;
|
||||
|
||||
const service = await storage.createService({
|
||||
username,
|
||||
serviceFqn,
|
||||
serviceName: parsed.service,
|
||||
version: parsed.version,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
// Create offers
|
||||
const createdOffers = [];
|
||||
for (const offer of offers) {
|
||||
const createdOffer = await storage.createOffer({
|
||||
username,
|
||||
serviceId: service.id,
|
||||
serviceFqn,
|
||||
sdp: offer.sdp,
|
||||
ttl: offerTtl,
|
||||
});
|
||||
createdOffers.push({
|
||||
offerId: createdOffer.id,
|
||||
sdp: createdOffer.sdp,
|
||||
createdAt: createdOffer.createdAt,
|
||||
expiresAt: createdOffer.expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
serviceId: service.id,
|
||||
username: service.username,
|
||||
serviceFqn: service.serviceFqn,
|
||||
offers: createdOffers,
|
||||
createdAt: service.createdAt,
|
||||
expiresAt: service.expiresAt,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a service
|
||||
*/
|
||||
async deleteService(params, message, signature, storage, config) {
|
||||
const { serviceFqn } = params;
|
||||
const username = extractUsername(message);
|
||||
|
||||
if (!username) {
|
||||
throw new Error('Username required');
|
||||
}
|
||||
|
||||
// Verify authentication
|
||||
const auth = await verifyAuth(username, message, signature, storage);
|
||||
if (!auth.valid) {
|
||||
throw new Error(auth.error);
|
||||
}
|
||||
|
||||
const parsed = parseServiceFqn(serviceFqn);
|
||||
if (!parsed || !parsed.username) {
|
||||
throw new Error('Service FQN must include username');
|
||||
}
|
||||
|
||||
const service = await storage.getServiceByFqn(serviceFqn);
|
||||
if (!service) {
|
||||
throw new Error('Service not found');
|
||||
}
|
||||
|
||||
const deleted = await storage.deleteService(service.id, username);
|
||||
if (!deleted) {
|
||||
throw new Error('Service not found or not owned by this username');
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
/**
|
||||
* Answer an offer
|
||||
*/
|
||||
async answerOffer(params, message, signature, storage, config) {
|
||||
const { serviceFqn, offerId, sdp } = params;
|
||||
const username = extractUsername(message);
|
||||
|
||||
if (!username) {
|
||||
throw new Error('Username required');
|
||||
}
|
||||
|
||||
// Verify authentication
|
||||
const auth = await verifyAuth(username, message, signature, storage);
|
||||
if (!auth.valid) {
|
||||
throw new Error(auth.error);
|
||||
}
|
||||
|
||||
if (!sdp || typeof sdp !== 'string' || sdp.length === 0) {
|
||||
throw new Error('Invalid SDP');
|
||||
}
|
||||
|
||||
if (sdp.length > 64 * 1024) {
|
||||
throw new Error('SDP too large (max 64KB)');
|
||||
}
|
||||
|
||||
const offer = await storage.getOfferById(offerId);
|
||||
if (!offer) {
|
||||
throw new Error('Offer not found');
|
||||
}
|
||||
|
||||
if (offer.answererUsername) {
|
||||
throw new Error('Offer already answered');
|
||||
}
|
||||
|
||||
await storage.answerOffer(offerId, username, sdp);
|
||||
|
||||
return { success: true, offerId };
|
||||
},
|
||||
|
||||
/**
|
||||
* Get answer for an offer
|
||||
*/
|
||||
async getOfferAnswer(params, message, signature, storage, config) {
|
||||
const { serviceFqn, offerId } = params;
|
||||
const username = extractUsername(message);
|
||||
|
||||
if (!username) {
|
||||
throw new Error('Username required');
|
||||
}
|
||||
|
||||
// Verify authentication
|
||||
const auth = await verifyAuth(username, message, signature, storage);
|
||||
if (!auth.valid) {
|
||||
throw new Error(auth.error);
|
||||
}
|
||||
|
||||
const offer = await storage.getOfferById(offerId);
|
||||
if (!offer) {
|
||||
throw new Error('Offer not found');
|
||||
}
|
||||
|
||||
if (offer.username !== username) {
|
||||
throw new Error('Not authorized to access this offer');
|
||||
}
|
||||
|
||||
if (!offer.answererUsername || !offer.answerSdp) {
|
||||
throw new Error('Offer not yet answered');
|
||||
}
|
||||
|
||||
return {
|
||||
sdp: offer.answerSdp,
|
||||
offerId: offer.id,
|
||||
answererId: offer.answererUsername,
|
||||
answeredAt: offer.answeredAt,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Combined polling for answers and ICE candidates
|
||||
*/
|
||||
async poll(params, message, signature, storage, config) {
|
||||
const { since } = params;
|
||||
const username = extractUsername(message);
|
||||
|
||||
if (!username) {
|
||||
throw new Error('Username required');
|
||||
}
|
||||
|
||||
// Verify authentication
|
||||
const auth = await verifyAuth(username, message, signature, storage);
|
||||
if (!auth.valid) {
|
||||
throw new Error(auth.error);
|
||||
}
|
||||
|
||||
const sinceTimestamp = since || 0;
|
||||
|
||||
// Get all answered offers
|
||||
const answeredOffers = await storage.getAnsweredOffers(username);
|
||||
const filteredAnswers = answeredOffers.filter(
|
||||
(offer) => offer.answeredAt && offer.answeredAt > sinceTimestamp
|
||||
);
|
||||
|
||||
// Get all user's offers
|
||||
const allOffers = await storage.getOffersByUsername(username);
|
||||
|
||||
// For each offer, get ICE candidates from both sides
|
||||
const iceCandidatesByOffer: Record<string, any[]> = {};
|
||||
|
||||
for (const offer of allOffers) {
|
||||
const offererCandidates = await storage.getIceCandidates(
|
||||
offer.id,
|
||||
'offerer',
|
||||
sinceTimestamp
|
||||
);
|
||||
const answererCandidates = await storage.getIceCandidates(
|
||||
offer.id,
|
||||
'answerer',
|
||||
sinceTimestamp
|
||||
);
|
||||
|
||||
const allCandidates = [
|
||||
...offererCandidates.map((c: any) => ({
|
||||
...c,
|
||||
role: 'offerer' as const,
|
||||
})),
|
||||
...answererCandidates.map((c: any) => ({
|
||||
...c,
|
||||
role: 'answerer' as const,
|
||||
})),
|
||||
];
|
||||
|
||||
if (allCandidates.length > 0) {
|
||||
const isOfferer = offer.username === username;
|
||||
const filtered = allCandidates.filter((c) =>
|
||||
isOfferer ? c.role === 'answerer' : c.role === 'offerer'
|
||||
);
|
||||
|
||||
if (filtered.length > 0) {
|
||||
iceCandidatesByOffer[offer.id] = filtered;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
answers: filteredAnswers.map((offer) => ({
|
||||
offerId: offer.id,
|
||||
serviceId: offer.serviceId,
|
||||
answererId: offer.answererUsername,
|
||||
sdp: offer.answerSdp,
|
||||
answeredAt: offer.answeredAt,
|
||||
})),
|
||||
iceCandidates: iceCandidatesByOffer,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Add ICE candidates
|
||||
*/
|
||||
async addIceCandidates(params, message, signature, storage, config) {
|
||||
const { serviceFqn, offerId, candidates } = params;
|
||||
const username = extractUsername(message);
|
||||
|
||||
if (!username) {
|
||||
throw new Error('Username required');
|
||||
}
|
||||
|
||||
// Verify authentication
|
||||
const auth = await verifyAuth(username, message, signature, storage);
|
||||
if (!auth.valid) {
|
||||
throw new Error(auth.error);
|
||||
}
|
||||
|
||||
if (!Array.isArray(candidates) || candidates.length === 0) {
|
||||
throw new Error('Missing or invalid required parameter: candidates');
|
||||
}
|
||||
|
||||
const offer = await storage.getOfferById(offerId);
|
||||
if (!offer) {
|
||||
throw new Error('Offer not found');
|
||||
}
|
||||
|
||||
const role = offer.username === username ? 'offerer' : 'answerer';
|
||||
const count = await storage.addIceCandidates(
|
||||
offerId,
|
||||
username,
|
||||
role,
|
||||
candidates
|
||||
);
|
||||
|
||||
return { count, offerId };
|
||||
},
|
||||
|
||||
/**
|
||||
* Get ICE candidates
|
||||
*/
|
||||
async getIceCandidates(params, message, signature, storage, config) {
|
||||
const { serviceFqn, offerId, since } = params;
|
||||
const username = extractUsername(message);
|
||||
|
||||
if (!username) {
|
||||
throw new Error('Username required');
|
||||
}
|
||||
|
||||
// Verify authentication
|
||||
const auth = await verifyAuth(username, message, signature, storage);
|
||||
if (!auth.valid) {
|
||||
throw new Error(auth.error);
|
||||
}
|
||||
|
||||
const sinceTimestamp = since || 0;
|
||||
|
||||
const offer = await storage.getOfferById(offerId);
|
||||
if (!offer) {
|
||||
throw new Error('Offer not found');
|
||||
}
|
||||
|
||||
const isOfferer = offer.username === username;
|
||||
const role = isOfferer ? 'answerer' : 'offerer';
|
||||
|
||||
const candidates = await storage.getIceCandidates(
|
||||
offerId,
|
||||
role,
|
||||
sinceTimestamp
|
||||
);
|
||||
|
||||
return {
|
||||
candidates: candidates.map((c: any) => ({
|
||||
candidate: c.candidate,
|
||||
createdAt: c.createdAt,
|
||||
})),
|
||||
offerId,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle RPC batch request
|
||||
*/
|
||||
export async function handleRpc(
|
||||
requests: RpcRequest[],
|
||||
storage: Storage,
|
||||
config: Config
|
||||
): Promise<RpcResponse[]> {
|
||||
const responses: RpcResponse[] = [];
|
||||
|
||||
for (const request of requests) {
|
||||
try {
|
||||
const { method, message, signature, params } = request;
|
||||
|
||||
// Validate request
|
||||
if (!method || typeof method !== 'string') {
|
||||
responses.push({
|
||||
success: false,
|
||||
error: 'Missing or invalid method',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!message || typeof message !== 'string') {
|
||||
responses.push({
|
||||
success: false,
|
||||
error: 'Missing or invalid message',
|
||||
});
|
||||
}
|
||||
|
||||
if (!signature || typeof signature !== 'string') {
|
||||
responses.push({
|
||||
success: false,
|
||||
error: 'Missing or invalid signature',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get handler
|
||||
const handler = handlers[method];
|
||||
if (!handler) {
|
||||
responses.push({
|
||||
success: false,
|
||||
error: `Unknown method: ${method}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Execute handler
|
||||
const result = await handler(
|
||||
params || {},
|
||||
message,
|
||||
signature,
|
||||
storage,
|
||||
config
|
||||
);
|
||||
|
||||
responses.push({
|
||||
success: true,
|
||||
result,
|
||||
});
|
||||
} catch (err) {
|
||||
responses.push({
|
||||
success: false,
|
||||
error: (err as Error).message || 'Internal server error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return responses;
|
||||
}
|
||||
Reference in New Issue
Block a user