diff --git a/README.md b/README.md index 7391c97..0eb75de 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,9 @@ Scalable peer-to-peer connection establishment with topic-based discovery, stateless authentication, and complete WebRTC signaling. **Related repositories:** -- [@xtr-dev/rondevu-client](https://www.npmjs.com/package/@xtr-dev/rondevu-client) - TypeScript client library -- [rondevu-demo](https://rondevu-demo.pages.dev) - Interactive demo +- [@xtr-dev/rondevu-client](https://github.com/xtr-dev/rondevu-client) - TypeScript client library ([npm](https://www.npmjs.com/package/@xtr-dev/rondevu-client)) +- [@xtr-dev/rondevu-server](https://github.com/xtr-dev/rondevu-server) - HTTP signaling server ([npm](https://www.npmjs.com/package/@xtr-dev/rondevu-server)) +- [@xtr-dev/rondevu-demo](https://github.com/xtr-dev/rondevu-demo) - Interactive demo ([live](https://rondevu-demo.pages.dev)) --- diff --git a/src/app.ts b/src/app.ts index f950480..2be5ddc 100644 --- a/src/app.ts +++ b/src/app.ts @@ -115,6 +115,16 @@ export function createApp(storage: Storage, config: Config) { return c.json({ error: 'SDP must be 64KB or less' }, 400); } + // Validate secret if provided + if (offer.secret !== undefined) { + if (typeof offer.secret !== 'string') { + return c.json({ error: 'Secret must be a string' }, 400); + } + if (offer.secret.length > 128) { + return c.json({ error: 'Secret must be 128 characters or less' }, 400); + } + } + // Validate topics if (!Array.isArray(offer.topics) || offer.topics.length === 0) { return c.json({ error: 'Each offer must have a non-empty topics array' }, 400); @@ -145,6 +155,7 @@ export function createApp(storage: Storage, config: Config) { sdp: offer.sdp, topics: offer.topics, expiresAt: Date.now() + ttl, + secret: offer.secret, }); } @@ -216,7 +227,8 @@ export function createApp(storage: Storage, config: Config) { sdp: o.sdp, topics: o.topics, expiresAt: o.expiresAt, - lastSeen: o.lastSeen + lastSeen: o.lastSeen, + hasSecret: !!o.secret // Indicate if secret is required without exposing it })), total: bloomParam ? total + excludePeerIds.length : total, returned: offers.length @@ -282,7 +294,8 @@ export function createApp(storage: Storage, config: Config) { sdp: o.sdp, topics: o.topics, expiresAt: o.expiresAt, - lastSeen: o.lastSeen + lastSeen: o.lastSeen, + hasSecret: !!o.secret // Indicate if secret is required without exposing it })), topics: Array.from(topicsSet) }, 200); @@ -311,6 +324,7 @@ export function createApp(storage: Storage, config: Config) { createdAt: o.createdAt, expiresAt: o.expiresAt, lastSeen: o.lastSeen, + secret: o.secret, // Owner can see the secret answererPeerId: o.answererPeerId, answeredAt: o.answeredAt })) @@ -354,7 +368,7 @@ export function createApp(storage: Storage, config: Config) { const offerId = c.req.param('offerId'); const peerId = getAuthenticatedPeerId(c); const body = await c.req.json(); - const { sdp } = body; + const { sdp, secret } = body; if (!sdp || typeof sdp !== 'string') { return c.json({ error: 'Missing or invalid required parameter: sdp' }, 400); @@ -364,7 +378,12 @@ export function createApp(storage: Storage, config: Config) { return c.json({ error: 'SDP must be 64KB or less' }, 400); } - const result = await storage.answerOffer(offerId, peerId, sdp); + // Validate secret if provided + if (secret !== undefined && typeof secret !== 'string') { + return c.json({ error: 'Secret must be a string' }, 400); + } + + const result = await storage.answerOffer(offerId, peerId, sdp, secret); if (!result.success) { return c.json({ error: result.error }, 400); diff --git a/src/storage/d1.ts b/src/storage/d1.ts index 6ed6647..1c95757 100644 --- a/src/storage/d1.ts +++ b/src/storage/d1.ts @@ -29,6 +29,7 @@ export class D1Storage implements Storage { created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, last_seen INTEGER NOT NULL, + secret TEXT, answerer_peer_id TEXT, answer_sdp TEXT, answered_at INTEGER @@ -75,9 +76,9 @@ export class D1Storage implements Storage { // Insert offer await this.db.prepare(` - INSERT INTO offers (id, peer_id, sdp, created_at, expires_at, last_seen) - VALUES (?, ?, ?, ?, ?, ?) - `).bind(id, offer.peerId, offer.sdp, now, offer.expiresAt, now).run(); + INSERT INTO offers (id, peer_id, sdp, created_at, expires_at, last_seen, secret) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).bind(id, offer.peerId, offer.sdp, now, offer.expiresAt, now, offer.secret || null).run(); // Insert topics for (const topic of offer.topics) { @@ -95,6 +96,7 @@ export class D1Storage implements Storage { createdAt: now, expiresAt: offer.expiresAt, lastSeen: now, + secret: offer.secret, }); } @@ -175,7 +177,8 @@ export class D1Storage implements Storage { async answerOffer( offerId: string, answererPeerId: string, - answerSdp: string + answerSdp: string, + secret?: string ): Promise<{ success: boolean; error?: string }> { // Check if offer exists and is not expired const offer = await this.getOfferById(offerId); @@ -187,6 +190,14 @@ export class D1Storage implements Storage { }; } + // Verify secret if offer is protected + if (offer.secret && offer.secret !== secret) { + return { + success: false, + error: 'Invalid or missing secret' + }; + } + // Check if offer already has an answerer if (offer.answererPeerId) { return { @@ -374,6 +385,7 @@ export class D1Storage implements Storage { createdAt: row.created_at, expiresAt: row.expires_at, lastSeen: row.last_seen, + secret: row.secret || undefined, answererPeerId: row.answerer_peer_id || undefined, answerSdp: row.answer_sdp || undefined, answeredAt: row.answered_at || undefined, diff --git a/src/storage/sqlite.ts b/src/storage/sqlite.ts index 63851be..23d525f 100644 --- a/src/storage/sqlite.ts +++ b/src/storage/sqlite.ts @@ -30,6 +30,7 @@ export class SQLiteStorage implements Storage { created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, last_seen INTEGER NOT NULL, + secret TEXT, answerer_peer_id TEXT, answer_sdp TEXT, answered_at INTEGER @@ -83,8 +84,8 @@ export class SQLiteStorage implements Storage { // Use transaction for atomic creation const transaction = this.db.transaction((offersWithIds: (CreateOfferRequest & { id: string })[]) => { const offerStmt = this.db.prepare(` - INSERT INTO offers (id, peer_id, sdp, created_at, expires_at, last_seen) - VALUES (?, ?, ?, ?, ?, ?) + INSERT INTO offers (id, peer_id, sdp, created_at, expires_at, last_seen, secret) + VALUES (?, ?, ?, ?, ?, ?, ?) `); const topicStmt = this.db.prepare(` @@ -102,7 +103,8 @@ export class SQLiteStorage implements Storage { offer.sdp, now, offer.expiresAt, - now + now, + offer.secret || null ); // Insert topics @@ -118,6 +120,7 @@ export class SQLiteStorage implements Storage { createdAt: now, expiresAt: offer.expiresAt, lastSeen: now, + secret: offer.secret, }); } }); @@ -195,7 +198,8 @@ export class SQLiteStorage implements Storage { async answerOffer( offerId: string, answererPeerId: string, - answerSdp: string + answerSdp: string, + secret?: string ): Promise<{ success: boolean; error?: string }> { // Check if offer exists and is not expired const offer = await this.getOfferById(offerId); @@ -207,6 +211,14 @@ export class SQLiteStorage implements Storage { }; } + // Verify secret if offer is protected + if (offer.secret && offer.secret !== secret) { + return { + success: false, + error: 'Invalid or missing secret' + }; + } + // Check if offer already has an answerer if (offer.answererPeerId) { return { @@ -382,6 +394,7 @@ export class SQLiteStorage implements Storage { createdAt: row.created_at, expiresAt: row.expires_at, lastSeen: row.last_seen, + secret: row.secret || undefined, answererPeerId: row.answerer_peer_id || undefined, answerSdp: row.answer_sdp || undefined, answeredAt: row.answered_at || undefined, diff --git a/src/storage/types.ts b/src/storage/types.ts index f609470..76841ac 100644 --- a/src/storage/types.ts +++ b/src/storage/types.ts @@ -9,6 +9,7 @@ export interface Offer { createdAt: number; expiresAt: number; lastSeen: number; + secret?: string; answererPeerId?: string; answerSdp?: string; answeredAt?: number; @@ -44,6 +45,7 @@ export interface CreateOfferRequest { sdp: string; topics: string[]; expiresAt: number; + secret?: string; } /** @@ -100,9 +102,10 @@ export interface Storage { * @param offerId Offer identifier * @param answererPeerId Answerer's peer ID * @param answerSdp WebRTC answer SDP + * @param secret Optional secret for protected offers * @returns Success status and optional error message */ - answerOffer(offerId: string, answererPeerId: string, answerSdp: string): Promise<{ + answerOffer(offerId: string, answererPeerId: string, answerSdp: string, secret?: string): Promise<{ success: boolean; error?: string; }>;