mirror of
https://github.com/xtr-dev/rondevu-client.git
synced 2025-12-12 20:03:24 +00:00
refactor: Update client to use RPC interface
BREAKING CHANGES:
- All API calls now go to POST /rpc endpoint
- Request format: { method, message, signature, params }
- Response format: { success, result } or { success: false, error }
- Simplified API methods to match RPC methods
- Removed checkUsername, added isUsernameAvailable
- Renamed postOfferAnswer to answerOffer
- Removed discoverService/discoverServices (use getService)
Changes:
- Completely refactored api.ts for RPC interface
- Updated rondevu.ts wrapper methods
- Updated rondevu-signaler.ts to use new API
- Fixed exports in index.ts
This commit is contained in:
586
src/api.ts
586
src/api.ts
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Rondevu API Client - Single class for all API endpoints
|
||||
* Rondevu API Client - RPC interface
|
||||
*/
|
||||
|
||||
import * as ed25519 from '@noble/ed25519'
|
||||
@@ -14,24 +14,6 @@ export interface Keypair {
|
||||
privateKey: string
|
||||
}
|
||||
|
||||
export interface OfferRequest {
|
||||
sdp: string
|
||||
topics?: string[]
|
||||
ttl?: number
|
||||
secret?: string
|
||||
}
|
||||
|
||||
export interface Offer {
|
||||
id: string
|
||||
peerId: string
|
||||
sdp: string
|
||||
topics: string[]
|
||||
ttl: number
|
||||
createdAt: number
|
||||
expiresAt: number
|
||||
answererPeerId?: string
|
||||
}
|
||||
|
||||
export interface OfferRequest {
|
||||
sdp: string
|
||||
}
|
||||
@@ -82,7 +64,26 @@ function base64ToBytes(base64: string): Uint8Array {
|
||||
}
|
||||
|
||||
/**
|
||||
* RondevuAPI - Complete API client for Rondevu signaling server
|
||||
* RPC request format
|
||||
*/
|
||||
interface RpcRequest {
|
||||
method: string
|
||||
message: string
|
||||
signature: string
|
||||
params?: any
|
||||
}
|
||||
|
||||
/**
|
||||
* RPC response format
|
||||
*/
|
||||
interface RpcResponse {
|
||||
success: boolean
|
||||
result?: any
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* RondevuAPI - RPC-based API client for Rondevu signaling server
|
||||
*/
|
||||
export class RondevuAPI {
|
||||
constructor(
|
||||
@@ -92,21 +93,67 @@ export class RondevuAPI {
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Generate authentication parameters (username, signature, message) for API calls
|
||||
* Generate authentication parameters for RPC calls
|
||||
*/
|
||||
private async generateAuthParams(action: string, params: string = ''): Promise<{
|
||||
username: string;
|
||||
signature: string;
|
||||
message: string;
|
||||
private async generateAuth(method: string, params: string = ''): Promise<{
|
||||
message: string
|
||||
signature: string
|
||||
}> {
|
||||
const timestamp = Date.now();
|
||||
const timestamp = Date.now()
|
||||
const message = params
|
||||
? `${action}:${this.username}:${params}:${timestamp}`
|
||||
: `${action}:${this.username}:${timestamp}`;
|
||||
? `${method}:${this.username}:${params}:${timestamp}`
|
||||
: `${method}:${this.username}:${timestamp}`
|
||||
|
||||
const signature = await RondevuAPI.signMessage(message, this.keypair.privateKey);
|
||||
const signature = await RondevuAPI.signMessage(message, this.keypair.privateKey)
|
||||
|
||||
return { username: this.username, signature, message };
|
||||
return { message, signature }
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute RPC call
|
||||
*/
|
||||
private async rpc(request: RpcRequest): Promise<any> {
|
||||
const response = await fetch(`${this.baseUrl}/rpc`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const result: RpcResponse = await response.json()
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'RPC call failed')
|
||||
}
|
||||
|
||||
return result.result
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute batch RPC calls
|
||||
*/
|
||||
private async rpcBatch(requests: RpcRequest[]): Promise<any[]> {
|
||||
const response = await fetch(`${this.baseUrl}/rpc`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(requests),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const results: RpcResponse[] = await response.json()
|
||||
|
||||
return results.map((result, i) => {
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || `RPC call ${i} failed`)
|
||||
}
|
||||
return result.result
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -133,370 +180,235 @@ export class RondevuAPI {
|
||||
const privateKey = base64ToBytes(privateKeyBase64)
|
||||
const encoder = new TextEncoder()
|
||||
const messageBytes = encoder.encode(message)
|
||||
|
||||
const signature = await ed25519.signAsync(messageBytes, privateKey)
|
||||
|
||||
return bytesToBase64(signature)
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a signature
|
||||
* Verify an Ed25519 signature
|
||||
*/
|
||||
static async verifySignature(
|
||||
message: string,
|
||||
signatureBase64: string,
|
||||
publicKeyBase64: string
|
||||
): Promise<boolean> {
|
||||
const publicKey = base64ToBytes(publicKeyBase64)
|
||||
const signature = base64ToBytes(signatureBase64)
|
||||
const encoder = new TextEncoder()
|
||||
const messageBytes = encoder.encode(message)
|
||||
try {
|
||||
const signature = base64ToBytes(signatureBase64)
|
||||
const publicKey = base64ToBytes(publicKeyBase64)
|
||||
const encoder = new TextEncoder()
|
||||
const messageBytes = encoder.encode(message)
|
||||
|
||||
return await ed25519.verifyAsync(signature, messageBytes, publicKey)
|
||||
return await ed25519.verifyAsync(signature, messageBytes, publicKey)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Offers
|
||||
// Username Management
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Create one or more offers
|
||||
* Check if a username is available
|
||||
*/
|
||||
async createOffers(offers: OfferRequest[]): Promise<Offer[]> {
|
||||
const auth = await this.generateAuthParams('createOffers');
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/offers`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ offers, ...auth }),
|
||||
async isUsernameAvailable(username: string): Promise<boolean> {
|
||||
const auth = await this.generateAuth('getUser', username)
|
||||
const result = await this.rpc({
|
||||
method: 'getUser',
|
||||
message: auth.message,
|
||||
signature: auth.signature,
|
||||
params: { username },
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||
throw new Error(`Failed to create offers: ${error.error || response.statusText}`)
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
return result.available
|
||||
}
|
||||
|
||||
/**
|
||||
* Get offer by ID
|
||||
* Claim a username
|
||||
*/
|
||||
async getOffer(offerId: string): Promise<Offer> {
|
||||
const auth = await this.generateAuthParams('getOffer', offerId);
|
||||
const url = new URL(`${this.baseUrl}/offers/${offerId}`);
|
||||
url.searchParams.set('username', auth.username);
|
||||
url.searchParams.set('signature', auth.signature);
|
||||
url.searchParams.set('message', auth.message);
|
||||
|
||||
const response = await fetch(url.toString())
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||
throw new Error(`Failed to get offer: ${error.error || response.statusText}`)
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Answer a specific offer from a service
|
||||
*/
|
||||
async postOfferAnswer(serviceFqn: string, offerId: string, sdp: string): Promise<{ success: boolean; offerId: string }> {
|
||||
const auth = await this.generateAuthParams('answerOffer', `${serviceFqn}:${offerId}`);
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/services/${encodeURIComponent(serviceFqn)}/offers/${offerId}/answer`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ sdp, ...auth }),
|
||||
async claimUsername(username: string, publicKey: string): Promise<void> {
|
||||
const auth = await this.generateAuth('claim', username)
|
||||
await this.rpc({
|
||||
method: 'claimUsername',
|
||||
message: auth.message,
|
||||
signature: auth.signature,
|
||||
params: { username, publicKey },
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||
throw new Error(`Failed to answer offer: ${error.error || response.statusText}`)
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined polling for answers and ICE candidates
|
||||
* Returns all answered offers and ICE candidates since timestamp
|
||||
* Check if current username is claimed
|
||||
*/
|
||||
async poll(since?: number): Promise<{
|
||||
answers: Array<{
|
||||
offerId: string;
|
||||
serviceId?: string;
|
||||
answererId: string;
|
||||
sdp: string;
|
||||
answeredAt: number;
|
||||
}>;
|
||||
iceCandidates: Record<string, Array<{
|
||||
candidate: any;
|
||||
role: 'offerer' | 'answerer';
|
||||
peerId: string;
|
||||
createdAt: number;
|
||||
}>>;
|
||||
}> {
|
||||
const auth = await this.generateAuthParams('poll', since?.toString() || '');
|
||||
const url = new URL(`${this.baseUrl}/poll`);
|
||||
async isUsernameClaimed(): Promise<boolean> {
|
||||
const auth = await this.generateAuth('getUser', this.username)
|
||||
const result = await this.rpc({
|
||||
method: 'getUser',
|
||||
message: auth.message,
|
||||
signature: auth.signature,
|
||||
params: { username: this.username },
|
||||
})
|
||||
return !result.available
|
||||
}
|
||||
|
||||
if (since) {
|
||||
url.searchParams.set('since', since.toString());
|
||||
}
|
||||
url.searchParams.set('username', auth.username);
|
||||
url.searchParams.set('signature', auth.signature);
|
||||
url.searchParams.set('message', auth.message);
|
||||
// ============================================
|
||||
// Service Management
|
||||
// ============================================
|
||||
|
||||
const response = await fetch(url.toString())
|
||||
/**
|
||||
* Publish a service
|
||||
*/
|
||||
async publishService(service: ServiceRequest): Promise<Service> {
|
||||
const auth = await this.generateAuth('publishService', service.serviceFqn)
|
||||
return await this.rpc({
|
||||
method: 'publishService',
|
||||
message: auth.message,
|
||||
signature: auth.signature,
|
||||
params: {
|
||||
serviceFqn: service.serviceFqn,
|
||||
offers: service.offers,
|
||||
ttl: service.ttl,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||
throw new Error(`Failed to poll: ${error.error || response.statusText}`)
|
||||
}
|
||||
/**
|
||||
* Get service by FQN (direct lookup, random, or paginated)
|
||||
*/
|
||||
async getService(
|
||||
serviceFqn: string,
|
||||
options?: { limit?: number; offset?: number }
|
||||
): Promise<any> {
|
||||
const auth = await this.generateAuth('getService', serviceFqn)
|
||||
return await this.rpc({
|
||||
method: 'getService',
|
||||
message: auth.message,
|
||||
signature: auth.signature,
|
||||
params: {
|
||||
serviceFqn,
|
||||
...options,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
/**
|
||||
* Delete a service
|
||||
*/
|
||||
async deleteService(serviceFqn: string): Promise<void> {
|
||||
const auth = await this.generateAuth('deleteService', serviceFqn)
|
||||
await this.rpc({
|
||||
method: 'deleteService',
|
||||
message: auth.message,
|
||||
signature: auth.signature,
|
||||
params: { serviceFqn },
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// WebRTC Signaling
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Answer an offer
|
||||
*/
|
||||
async answerOffer(serviceFqn: string, offerId: string, sdp: string): Promise<void> {
|
||||
const auth = await this.generateAuth('answerOffer', offerId)
|
||||
await this.rpc({
|
||||
method: 'answerOffer',
|
||||
message: auth.message,
|
||||
signature: auth.signature,
|
||||
params: { serviceFqn, offerId, sdp },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get answer for a specific offer (offerer polls this)
|
||||
*/
|
||||
async getOfferAnswer(serviceFqn: string, offerId: string): Promise<{ sdp: string; offerId: string; answererId: string; answeredAt: number } | null> {
|
||||
const auth = await this.generateAuthParams('getOfferAnswer', `${serviceFqn}:${offerId}`);
|
||||
const url = new URL(`${this.baseUrl}/services/${encodeURIComponent(serviceFqn)}/offers/${offerId}/answer`);
|
||||
url.searchParams.set('username', auth.username);
|
||||
url.searchParams.set('signature', auth.signature);
|
||||
url.searchParams.set('message', auth.message);
|
||||
|
||||
const response = await fetch(url.toString())
|
||||
|
||||
if (!response.ok) {
|
||||
// 404 means not yet answered
|
||||
if (response.status === 404) {
|
||||
async getOfferAnswer(
|
||||
serviceFqn: string,
|
||||
offerId: string
|
||||
): Promise<{ sdp: string; offerId: string; answererId: string; answeredAt: number } | null> {
|
||||
try {
|
||||
const auth = await this.generateAuth('getOfferAnswer', offerId)
|
||||
return await this.rpc({
|
||||
method: 'getOfferAnswer',
|
||||
message: auth.message,
|
||||
signature: auth.signature,
|
||||
params: { serviceFqn, offerId },
|
||||
})
|
||||
} catch (err) {
|
||||
if ((err as Error).message.includes('not yet answered')) {
|
||||
return null
|
||||
}
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||
throw new Error(`Failed to get answer: ${error.error || response.statusText}`)
|
||||
throw err
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Search offers by topic
|
||||
* Combined polling for answers and ICE candidates
|
||||
*/
|
||||
async searchOffers(topic: string): Promise<Offer[]> {
|
||||
const auth = await this.generateAuthParams('searchOffers', topic);
|
||||
const url = new URL(`${this.baseUrl}/offers`);
|
||||
url.searchParams.set('topic', topic);
|
||||
url.searchParams.set('username', auth.username);
|
||||
url.searchParams.set('signature', auth.signature);
|
||||
url.searchParams.set('message', auth.message);
|
||||
|
||||
const response = await fetch(url.toString())
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||
throw new Error(`Failed to search offers: ${error.error || response.statusText}`)
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
async poll(since?: number): Promise<{
|
||||
answers: Array<{
|
||||
offerId: string
|
||||
serviceId?: string
|
||||
answererId: string
|
||||
sdp: string
|
||||
answeredAt: number
|
||||
}>
|
||||
iceCandidates: Record<
|
||||
string,
|
||||
Array<{
|
||||
candidate: any
|
||||
role: 'offerer' | 'answerer'
|
||||
peerId: string
|
||||
createdAt: number
|
||||
}>
|
||||
>
|
||||
}> {
|
||||
const auth = await this.generateAuth('poll')
|
||||
return await this.rpc({
|
||||
method: 'poll',
|
||||
message: auth.message,
|
||||
signature: auth.signature,
|
||||
params: { since },
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ICE Candidates
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Add ICE candidates to a specific offer
|
||||
*/
|
||||
async addOfferIceCandidates(serviceFqn: string, offerId: string, candidates: RTCIceCandidateInit[]): Promise<{ count: number; offerId: string }> {
|
||||
const auth = await this.generateAuthParams('addIceCandidates', `${serviceFqn}:${offerId}`);
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/services/${encodeURIComponent(serviceFqn)}/offers/${offerId}/ice-candidates`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ candidates, ...auth }),
|
||||
async addOfferIceCandidates(
|
||||
serviceFqn: string,
|
||||
offerId: string,
|
||||
candidates: RTCIceCandidateInit[]
|
||||
): Promise<{ count: number; offerId: string }> {
|
||||
const auth = await this.generateAuth('addIceCandidates', offerId)
|
||||
return await this.rpc({
|
||||
method: 'addIceCandidates',
|
||||
message: auth.message,
|
||||
signature: auth.signature,
|
||||
params: { serviceFqn, offerId, candidates },
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||
throw new Error(`Failed to add ICE candidates: ${error.error || response.statusText}`)
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ICE candidates for a specific offer (with polling support)
|
||||
* Get ICE candidates for a specific offer
|
||||
*/
|
||||
async getOfferIceCandidates(serviceFqn: string, offerId: string, since: number = 0): Promise<{ candidates: IceCandidate[]; offerId: string }> {
|
||||
const auth = await this.generateAuthParams('getIceCandidates', `${serviceFqn}:${offerId}:${since}`);
|
||||
const url = new URL(`${this.baseUrl}/services/${encodeURIComponent(serviceFqn)}/offers/${offerId}/ice-candidates`)
|
||||
url.searchParams.set('since', since.toString())
|
||||
url.searchParams.set('username', auth.username);
|
||||
url.searchParams.set('signature', auth.signature);
|
||||
url.searchParams.set('message', auth.message);
|
||||
async getOfferIceCandidates(
|
||||
serviceFqn: string,
|
||||
offerId: string,
|
||||
since: number = 0
|
||||
): Promise<{ candidates: IceCandidate[]; offerId: string }> {
|
||||
const auth = await this.generateAuth('getIceCandidates', `${offerId}:${since}`)
|
||||
const result = await this.rpc({
|
||||
method: 'getIceCandidates',
|
||||
message: auth.message,
|
||||
signature: auth.signature,
|
||||
params: { serviceFqn, offerId, since },
|
||||
})
|
||||
|
||||
const response = await fetch(url.toString())
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||
throw new Error(`Failed to get ICE candidates: ${error.error || response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return {
|
||||
candidates: data.candidates || [],
|
||||
offerId: data.offerId
|
||||
candidates: result.candidates || [],
|
||||
offerId: result.offerId,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Services
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Publish a service
|
||||
* Service FQN must include username: service:version@username
|
||||
*/
|
||||
async publishService(service: ServiceRequest): Promise<Service> {
|
||||
const response = await fetch(`${this.baseUrl}/services`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...service,
|
||||
username: this.username
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||
throw new Error(`Failed to publish service: ${error.error || response.statusText}`)
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get service by FQN (with username) - Direct lookup
|
||||
* Example: chat:1.0.0@alice
|
||||
*/
|
||||
async getService(serviceFqn: string): Promise<{ serviceId: string; username: string; serviceFqn: string; offerId: string; sdp: string; createdAt: number; expiresAt: number }> {
|
||||
const auth = await this.generateAuthParams('getService', serviceFqn);
|
||||
const url = new URL(`${this.baseUrl}/services/${encodeURIComponent(serviceFqn)}`);
|
||||
url.searchParams.set('username', auth.username);
|
||||
url.searchParams.set('signature', auth.signature);
|
||||
url.searchParams.set('message', auth.message);
|
||||
|
||||
const response = await fetch(url.toString())
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||
throw new Error(`Failed to get service: ${error.error || response.statusText}`)
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover a random available service without knowing the username
|
||||
* Example: chat:1.0.0 (without @username)
|
||||
*/
|
||||
async discoverService(serviceVersion: string): Promise<{ serviceId: string; username: string; serviceFqn: string; offerId: string; sdp: string; createdAt: number; expiresAt: number }> {
|
||||
const auth = await this.generateAuthParams('discoverService', serviceVersion);
|
||||
const url = new URL(`${this.baseUrl}/services/${encodeURIComponent(serviceVersion)}`);
|
||||
url.searchParams.set('username', auth.username);
|
||||
url.searchParams.set('signature', auth.signature);
|
||||
url.searchParams.set('message', auth.message);
|
||||
|
||||
const response = await fetch(url.toString())
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||
throw new Error(`Failed to discover service: ${error.error || response.statusText}`)
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover multiple available services with pagination
|
||||
* Example: chat:1.0.0 (without @username)
|
||||
*/
|
||||
async discoverServices(serviceVersion: string, limit: number = 10, offset: number = 0): Promise<{ services: Array<{ serviceId: string; username: string; serviceFqn: string; offerId: string; sdp: string; createdAt: number; expiresAt: number }>; count: number; limit: number; offset: number }> {
|
||||
const auth = await this.generateAuthParams('discoverServices', `${serviceVersion}:${limit}:${offset}`);
|
||||
const url = new URL(`${this.baseUrl}/services/${encodeURIComponent(serviceVersion)}`)
|
||||
url.searchParams.set('limit', limit.toString())
|
||||
url.searchParams.set('offset', offset.toString())
|
||||
url.searchParams.set('username', auth.username);
|
||||
url.searchParams.set('signature', auth.signature);
|
||||
url.searchParams.set('message', auth.message);
|
||||
|
||||
const response = await fetch(url.toString())
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||
throw new Error(`Failed to discover services: ${error.error || response.statusText}`)
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
|
||||
// ============================================
|
||||
// Usernames
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Check if username is available
|
||||
*/
|
||||
async checkUsername(username: string): Promise<{ available: boolean; publicKey?: string; claimedAt?: number; expiresAt?: number }> {
|
||||
const response = await fetch(
|
||||
`${this.baseUrl}/users/${encodeURIComponent(username)}`
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||
throw new Error(`Failed to check username: ${error.error || response.statusText}`)
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim a username (requires Ed25519 signature)
|
||||
*/
|
||||
async claimUsername(
|
||||
username: string,
|
||||
publicKey: string,
|
||||
signature: string,
|
||||
message: string
|
||||
): Promise<{ success: boolean; username: string }> {
|
||||
const response = await fetch(`${this.baseUrl}/users/${encodeURIComponent(username)}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
publicKey,
|
||||
signature,
|
||||
message,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||
throw new Error(`Failed to claim username: ${error.error || response.statusText}`)
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,9 +16,9 @@ export type {
|
||||
export type {
|
||||
Keypair,
|
||||
OfferRequest,
|
||||
Offer,
|
||||
ServiceRequest,
|
||||
Service,
|
||||
ServiceOffer,
|
||||
IceCandidate,
|
||||
} from './api.js'
|
||||
|
||||
|
||||
@@ -111,8 +111,7 @@ export class RondevuSignaler implements Signaler {
|
||||
}
|
||||
|
||||
// Send answer to the service
|
||||
const result = await this.rondevu.getAPIPublic().postOfferAnswer(this.serviceFqn, this.offerId, answer.sdp)
|
||||
this.offerId = result.offerId
|
||||
await this.rondevu.getAPIPublic().answerOffer(this.serviceFqn, this.offerId, answer.sdp)
|
||||
this.isOfferer = false
|
||||
|
||||
// Start polling for ICE candidates (answerer uses separate endpoint)
|
||||
|
||||
@@ -114,22 +114,19 @@ export class Rondevu {
|
||||
}
|
||||
|
||||
// Check if username is already claimed
|
||||
const check = await this.getAPI().checkUsername(this.username)
|
||||
if (!check.available) {
|
||||
// Verify it's claimed by us
|
||||
if (check.publicKey === this.keypair.publicKey) {
|
||||
const available = await this.getAPI().isUsernameAvailable(this.username)
|
||||
if (!available) {
|
||||
// Check if it's claimed by us
|
||||
const claimed = await this.getAPI().isUsernameClaimed()
|
||||
if (claimed) {
|
||||
this.usernameClaimed = true
|
||||
return
|
||||
}
|
||||
throw new Error(`Username "${this.username}" is already claimed by another user`)
|
||||
}
|
||||
|
||||
// Generate signature for username claim
|
||||
const message = `claim:${this.username}:${Date.now()}`
|
||||
const signature = await RondevuAPI.signMessage(message, this.keypair.privateKey)
|
||||
|
||||
// Claim the username
|
||||
await this.getAPI().claimUsername(this.username, this.keypair.publicKey, signature, message)
|
||||
await this.getAPI().claimUsername(this.username, this.keypair.publicKey)
|
||||
this.usernameClaimed = true
|
||||
}
|
||||
|
||||
@@ -152,19 +149,7 @@ export class Rondevu {
|
||||
}
|
||||
|
||||
try {
|
||||
const check = await this.getAPI().checkUsername(this.username)
|
||||
|
||||
// Debug logging
|
||||
console.log('[Rondevu] Username check:', {
|
||||
username: this.username,
|
||||
available: check.available,
|
||||
serverPublicKey: check.publicKey,
|
||||
localPublicKey: this.keypair.publicKey,
|
||||
match: check.publicKey === this.keypair.publicKey
|
||||
})
|
||||
|
||||
// Username is claimed if it's not available and owned by our public key
|
||||
const claimed = !check.available && check.publicKey === this.keypair.publicKey
|
||||
const claimed = await this.getAPI().isUsernameClaimed()
|
||||
|
||||
// Update internal flag to match server state
|
||||
this.usernameClaimed = claimed
|
||||
@@ -246,7 +231,7 @@ export class Rondevu {
|
||||
createdAt: number
|
||||
expiresAt: number
|
||||
}> {
|
||||
return await this.getAPI().discoverService(serviceVersion)
|
||||
return await this.getAPI().getService(serviceVersion)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -267,7 +252,7 @@ export class Rondevu {
|
||||
limit: number
|
||||
offset: number
|
||||
}> {
|
||||
return await this.getAPI().discoverServices(serviceVersion, limit, offset)
|
||||
return await this.getAPI().getService(serviceVersion, { limit, offset })
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -281,7 +266,8 @@ export class Rondevu {
|
||||
success: boolean
|
||||
offerId: string
|
||||
}> {
|
||||
return await this.getAPI().postOfferAnswer(serviceFqn, offerId, sdp)
|
||||
await this.getAPI().answerOffer(serviceFqn, offerId, sdp)
|
||||
return { success: true, offerId }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user