diff --git a/README.md b/README.md index 55c12a0..81c6e1a 100644 --- a/README.md +++ b/README.md @@ -77,15 +77,28 @@ Generates a cryptographically random 128-bit peer ID. } ``` -### Username Management +### User Management (RESTful) -#### `POST /usernames/claim` +#### `GET /users/:username` +Check username availability and claim status + +**Response:** +```json +{ + "username": "alice", + "available": false, + "claimedAt": 1733404800000, + "expiresAt": 1765027200000, + "publicKey": "..." +} +``` + +#### `POST /users/:username` Claim a username with cryptographic proof **Request:** ```json { - "username": "alice", "publicKey": "base64-encoded-ed25519-public-key", "signature": "base64-encoded-signature", "message": "claim:alice:1733404800000" @@ -107,21 +120,7 @@ Claim a username with cryptographic proof - Timestamp must be within 5 minutes (replay protection) - Expires after 365 days, auto-renewed on use -#### `GET /usernames/:username` -Check username availability and claim status - -**Response:** -```json -{ - "username": "alice", - "available": false, - "claimedAt": 1733404800000, - "expiresAt": 1765027200000, - "publicKey": "..." -} -``` - -#### `GET /usernames/:username/services` +#### `GET /users/:username/services` List all services for a username (privacy-preserving) **Response:** @@ -143,9 +142,28 @@ List all services for a username (privacy-preserving) } ``` -### Service Management +#### `GET /users/:username/services/:fqn` +Get specific service by username and FQN (single request) -#### `POST /services` +**Response:** +```json +{ + "uuid": "abc123", + "serviceId": "service-id", + "username": "alice", + "serviceFqn": "chat.app@1.0.0", + "offerId": "offer-hash", + "sdp": "v=0...", + "isPublic": true, + "metadata": {}, + "createdAt": 1733404800000, + "expiresAt": 1733405100000 +} +``` + +### Service Management (RESTful) + +#### `POST /users/:username/services` Publish a service (requires authentication and username signature) **Headers:** @@ -154,7 +172,6 @@ Publish a service (requires authentication and username signature) **Request:** ```json { - "username": "alice", "serviceFqn": "com.example.chat@1.0.0", "sdp": "v=0...", "ttl": 300000, @@ -165,12 +182,18 @@ Publish a service (requires authentication and username signature) } ``` -**Response:** +**Response (Full service details):** ```json { - "serviceId": "uuid-v4", "uuid": "uuid-v4-for-index", + "serviceId": "uuid-v4", + "username": "alice", + "serviceFqn": "com.example.chat@1.0.0", "offerId": "offer-hash-id", + "sdp": "v=0...", + "isPublic": false, + "metadata": { "description": "Chat service" }, + "createdAt": 1733404800000, "expiresAt": 1733405100000 } ``` @@ -203,7 +226,7 @@ Get service details by UUID } ``` -#### `DELETE /services/:serviceId` +#### `DELETE /users/:username/services/:fqn` Unpublish a service (requires authentication and ownership) **Headers:** @@ -275,8 +298,8 @@ Answer an offer (locks it to answerer) } ``` -#### `GET /offers/answers` -Poll for answers to your offers +#### `GET /offers/:offerId/answer` +Get answer for a specific offer #### `POST /offers/:offerId/ice-candidates` Post ICE candidates for an offer diff --git a/package-lock.json b/package-lock.json index 8755654..6ede440 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@xtr-dev/rondevu-server", - "version": "0.1.5", + "version": "0.2.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@xtr-dev/rondevu-server", - "version": "0.1.5", + "version": "0.2.5", "dependencies": { "@hono/node-server": "^1.19.6", "@noble/ed25519": "^3.0.0", diff --git a/package.json b/package.json index 118c540..9f97d80 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@xtr-dev/rondevu-server", - "version": "0.2.4", + "version": "0.2.5", "description": "DNS-like WebRTC signaling server with username claiming and service discovery", "main": "dist/index.js", "scripts": { diff --git a/src/app.ts b/src/app.ts index 6023ecd..bfa1c52 100644 --- a/src/app.ts +++ b/src/app.ts @@ -8,6 +8,7 @@ import type { Context } from 'hono'; /** * Creates the Hono application with username and service-based WebRTC signaling + * RESTful API design - v0.11.0 */ export function createApp(storage: Storage, config: Config) { const app = new Hono(); @@ -78,58 +79,13 @@ export function createApp(storage: Storage, config: Config) { } }); - // ===== Username Management ===== + // ===== User Management (RESTful) ===== /** - * POST /usernames/claim - * Claim a username with cryptographic proof - */ - app.post('/usernames/claim', async (c) => { - try { - const body = await c.req.json(); - const { username, publicKey, signature, message } = body; - - if (!username || !publicKey || !signature || !message) { - return c.json({ error: 'Missing required parameters: username, publicKey, signature, message' }, 400); - } - - // Validate claim - const validation = await validateUsernameClaim(username, publicKey, signature, message); - if (!validation.valid) { - return c.json({ error: validation.error }, 400); - } - - // Attempt to claim username - try { - const claimed = await storage.claimUsername({ - username, - publicKey, - signature, - message - }); - - return c.json({ - username: claimed.username, - claimedAt: claimed.claimedAt, - expiresAt: claimed.expiresAt - }, 200); - } catch (err: any) { - if (err.message?.includes('already claimed')) { - return c.json({ error: 'Username already claimed by different public key' }, 409); - } - throw err; - } - } catch (err) { - console.error('Error claiming username:', err); - return c.json({ error: 'Internal server error' }, 500); - } - }); - - /** - * GET /usernames/:username + * GET /users/:username * Check if username is available or get claim info */ - app.get('/usernames/:username', async (c) => { + app.get('/users/:username', async (c) => { try { const username = c.req.param('username'); @@ -156,10 +112,56 @@ export function createApp(storage: Storage, config: Config) { }); /** - * GET /usernames/:username/services + * POST /users/:username + * Claim a username with cryptographic proof + */ + app.post('/users/:username', async (c) => { + try { + const username = c.req.param('username'); + const body = await c.req.json(); + const { publicKey, signature, message } = body; + + if (!publicKey || !signature || !message) { + return c.json({ error: 'Missing required parameters: publicKey, signature, message' }, 400); + } + + // Validate claim + const validation = await validateUsernameClaim(username, publicKey, signature, message); + if (!validation.valid) { + return c.json({ error: validation.error }, 400); + } + + // Attempt to claim username + try { + const claimed = await storage.claimUsername({ + username, + publicKey, + signature, + message + }); + + return c.json({ + username: claimed.username, + claimedAt: claimed.claimedAt, + expiresAt: claimed.expiresAt + }, 201); + } catch (err: any) { + if (err.message?.includes('already claimed')) { + return c.json({ error: 'Username already claimed by different public key' }, 409); + } + throw err; + } + } catch (err) { + console.error('Error claiming username:', err); + return c.json({ error: 'Internal server error' }, 500); + } + }); + + /** + * GET /users/:username/services * List services for a username (privacy-preserving) */ - app.get('/usernames/:username/services', async (c) => { + app.get('/users/:username/services', async (c) => { try { const username = c.req.param('username'); @@ -175,24 +177,79 @@ export function createApp(storage: Storage, config: Config) { } }); - // ===== Service Management ===== + /** + * GET /users/:username/services/:fqn + * Get service by username and FQN (replaces POST query endpoint) + */ + 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); + + if (!uuid) { + return c.json({ error: 'Service not found' }, 404); + } + + // Get full service details + const service = await storage.getServiceByUuid(uuid); + + if (!service) { + return c.json({ error: 'Service not found' }, 404); + } + + // Get all offers for this service + const serviceOffers = await storage.getOffersForService(service.id); + + if (serviceOffers.length === 0) { + return c.json({ error: 'No offers found for this service' }, 404); + } + + // Find an unanswered offer + const availableOffer = serviceOffers.find(offer => !offer.answererPeerId); + + if (!availableOffer) { + return c.json({ + error: 'No available offers', + message: 'All offers from this service are currently in use. Please try again later.' + }, 503); + } + + return c.json({ + uuid: uuid, + serviceId: service.id, + username: service.username, + serviceFqn: service.serviceFqn, + offerId: availableOffer.id, + sdp: availableOffer.sdp, + isPublic: service.isPublic, + metadata: service.metadata ? JSON.parse(service.metadata) : undefined, + createdAt: service.createdAt, + expiresAt: service.expiresAt + }, 200); + } catch (err) { + console.error('Error getting service:', err); + return c.json({ error: 'Internal server error' }, 500); + } + }); /** - * POST /services - * Publish a service + * POST /users/:username/services + * Publish a service with one or more offers (RESTful endpoint) */ - app.post('/services', authMiddleware, async (c) => { - let username: string | undefined; + app.post('/users/:username/services', authMiddleware, async (c) => { let serviceFqn: string | undefined; - let offers: any[] = []; + let createdOffers: any[] = []; try { + const username = c.req.param('username'); const body = await c.req.json(); - ({ username, serviceFqn } = body); - const { sdp, ttl, isPublic, metadata, signature, message } = body; + serviceFqn = body.serviceFqn; + const { offers, ttl, isPublic, metadata, signature, message } = body; - if (!username || !serviceFqn || !sdp) { - return c.json({ error: 'Missing required parameters: username, serviceFqn, sdp' }, 400); + if (!serviceFqn || !offers || !Array.isArray(offers) || offers.length === 0) { + return c.json({ error: 'Missing required parameters: serviceFqn, offers (must be non-empty array)' }, 400); } // Validate service FQN @@ -226,13 +283,15 @@ export function createApp(storage: Storage, config: Config) { } } - // Validate SDP - if (typeof sdp !== 'string' || sdp.length === 0) { - return c.json({ error: 'Invalid SDP' }, 400); - } + // Validate all offers + for (const offer of offers) { + if (!offer.sdp || typeof offer.sdp !== 'string' || offer.sdp.length === 0) { + return c.json({ error: 'Invalid SDP in offers array' }, 400); + } - if (sdp.length > 64 * 1024) { - return c.json({ error: 'SDP too large (max 64KB)' }, 400); + if (offer.sdp.length > 64 * 1024) { + return c.json({ error: 'SDP too large (max 64KB)' }, 400); + } } // Calculate expiry @@ -243,33 +302,40 @@ export function createApp(storage: Storage, config: Config) { ); const expiresAt = Date.now() + offerTtl; - // Create offer first - offers = await storage.createOffers([{ + // Prepare offer requests + const offerRequests = offers.map(offer => ({ peerId, - sdp, + sdp: offer.sdp, expiresAt - }]); + })); - if (offers.length === 0) { - return c.json({ error: 'Failed to create offer' }, 500); - } - - const offer = offers[0]; - - // Create service + // Create service with offers const result = await storage.createService({ username, serviceFqn, - offerId: offer.id, expiresAt, isPublic: isPublic || false, - metadata: metadata ? JSON.stringify(metadata) : undefined + metadata: metadata ? JSON.stringify(metadata) : undefined, + offers: offerRequests }); + createdOffers = result.offers; + + // Return full service details with all offers return c.json({ - serviceId: result.service.id, uuid: result.indexUuid, - offerId: offer.id, + serviceFqn: serviceFqn, + username: username, + serviceId: result.service.id, + offers: result.offers.map(o => ({ + offerId: o.id, + sdp: o.sdp, + createdAt: o.createdAt, + expiresAt: o.expiresAt + })), + isPublic: result.service.isPublic, + metadata: metadata, + createdAt: result.service.createdAt, expiresAt: result.service.expiresAt }, 201); } catch (err) { @@ -277,9 +343,9 @@ export function createApp(storage: Storage, config: Config) { console.error('Error details:', { message: (err as Error).message, stack: (err as Error).stack, - username, + username: c.req.param('username'), serviceFqn, - offerId: offers[0]?.id + offerIds: createdOffers.map(o => o.id) }); return c.json({ error: 'Internal server error', @@ -289,72 +355,26 @@ export function createApp(storage: Storage, config: Config) { }); /** - * GET /services/:uuid - * Get service details by index UUID - * Returns an available (unanswered) offer from the service's pool + * DELETE /users/:username/services/:fqn + * Delete a service by username and FQN (RESTful) */ - app.get('/services/:uuid', async (c) => { + app.delete('/users/:username/services/:fqn', authMiddleware, async (c) => { try { - const uuid = c.req.param('uuid'); + const username = c.req.param('username'); + const serviceFqn = decodeURIComponent(c.req.param('fqn')); + + // Find service by username and FQN + const uuid = await storage.queryService(username, serviceFqn); + if (!uuid) { + return c.json({ error: 'Service not found' }, 404); + } const service = await storage.getServiceByUuid(uuid); - if (!service) { return c.json({ error: 'Service not found' }, 404); } - // Get the initial offer to find the peer ID - const initialOffer = await storage.getOfferById(service.offerId); - - if (!initialOffer) { - return c.json({ error: 'Associated offer not found' }, 404); - } - - // Get all offers from this peer - const peerOffers = await storage.getOffersByPeerId(initialOffer.peerId); - - // Find an unanswered offer - const availableOffer = peerOffers.find(offer => !offer.answererPeerId); - - if (!availableOffer) { - return c.json({ - error: 'No available offers', - message: 'All offers from this service are currently in use. Please try again later.' - }, 503); - } - - return c.json({ - serviceId: service.id, - username: service.username, - serviceFqn: service.serviceFqn, - offerId: availableOffer.id, - sdp: availableOffer.sdp, - isPublic: service.isPublic, - metadata: service.metadata ? JSON.parse(service.metadata) : undefined, - createdAt: service.createdAt, - expiresAt: service.expiresAt - }, 200); - } catch (err) { - console.error('Error getting service:', err); - return c.json({ error: 'Internal server error' }, 500); - } - }); - - /** - * DELETE /services/:serviceId - * Delete a service (requires ownership) - */ - app.delete('/services/:serviceId', authMiddleware, async (c) => { - try { - const serviceId = c.req.param('serviceId'); - const body = await c.req.json(); - const { username } = body; - - if (!username) { - return c.json({ error: 'Missing required parameter: username' }, 400); - } - - const deleted = await storage.deleteService(serviceId, username); + const deleted = await storage.deleteService(service.id, username); if (!deleted) { return c.json({ error: 'Service not found or not owned by this username' }, 404); @@ -367,32 +387,53 @@ export function createApp(storage: Storage, config: Config) { } }); + // ===== Service Management (Legacy - for UUID-based access) ===== + /** - * POST /index/:username/query - * Query service by FQN (returns UUID) + * GET /services/:uuid + * Get service details by index UUID (kept for privacy) */ - app.post('/index/:username/query', async (c) => { + app.get('/services/:uuid', async (c) => { try { - const username = c.req.param('username'); - const body = await c.req.json(); - const { serviceFqn } = body; + const uuid = c.req.param('uuid'); - if (!serviceFqn) { - return c.json({ error: 'Missing required parameter: serviceFqn' }, 400); - } + const service = await storage.getServiceByUuid(uuid); - const uuid = await storage.queryService(username, serviceFqn); - - if (!uuid) { + if (!service) { return c.json({ error: 'Service not found' }, 404); } + // Get all offers for this service + const serviceOffers = await storage.getOffersForService(service.id); + + if (serviceOffers.length === 0) { + return c.json({ error: 'No offers found for this service' }, 404); + } + + // Find an unanswered offer + const availableOffer = serviceOffers.find(offer => !offer.answererPeerId); + + if (!availableOffer) { + return c.json({ + error: 'No available offers', + message: 'All offers from this service are currently in use. Please try again later.' + }, 503); + } + return c.json({ - uuid, - allowed: true + uuid: uuid, + serviceId: service.id, + username: service.username, + serviceFqn: service.serviceFqn, + offerId: availableOffer.id, + sdp: availableOffer.sdp, + isPublic: service.isPublic, + metadata: service.metadata ? JSON.parse(service.metadata) : undefined, + createdAt: service.createdAt, + expiresAt: service.expiresAt }, 200); } catch (err) { - console.error('Error querying service:', err); + console.error('Error getting service:', err); return c.json({ error: 'Internal server error' }, 500); } }); @@ -487,6 +528,35 @@ export function createApp(storage: Storage, config: Config) { } }); + /** + * GET /offers/:offerId + * Get offer details (added for completeness) + */ + app.get('/offers/:offerId', authMiddleware, async (c) => { + try { + const offerId = c.req.param('offerId'); + const offer = await storage.getOfferById(offerId); + + if (!offer) { + return c.json({ error: 'Offer not found' }, 404); + } + + return c.json({ + id: offer.id, + peerId: offer.peerId, + sdp: offer.sdp, + createdAt: offer.createdAt, + expiresAt: offer.expiresAt, + answererPeerId: offer.answererPeerId, + answered: !!offer.answererPeerId, + answerSdp: offer.answerSdp + }, 200); + } catch (err) { + console.error('Error getting offer:', err); + return c.json({ error: 'Internal server error' }, 500); + } + }); + /** * DELETE /offers/:offerId * Delete an offer @@ -547,24 +617,38 @@ export function createApp(storage: Storage, config: Config) { }); /** - * GET /offers/answers - * Get answers for authenticated peer's offers + * GET /offers/:offerId/answer + * Get answer for a specific offer (RESTful endpoint) */ - app.get('/offers/answers', authMiddleware, async (c) => { + app.get('/offers/:offerId/answer', authMiddleware, async (c) => { try { + const offerId = c.req.param('offerId'); const peerId = getAuthenticatedPeerId(c); - const offers = await storage.getAnsweredOffers(peerId); + + const offer = await storage.getOfferById(offerId); + + if (!offer) { + return c.json({ error: 'Offer not found' }, 404); + } + + // Verify ownership + if (offer.peerId !== peerId) { + return c.json({ error: 'Not authorized to view this answer' }, 403); + } + + // Check if answered + if (!offer.answererPeerId || !offer.answerSdp) { + return c.json({ error: 'Offer not yet answered' }, 404); + } return c.json({ - answers: offers.map(offer => ({ - offerId: offer.id, - answererId: offer.answererPeerId, - sdp: offer.answerSdp, - answeredAt: offer.answeredAt - })) + offerId: offer.id, + answererId: offer.answererPeerId, + sdp: offer.answerSdp, + answeredAt: offer.answeredAt }, 200); } catch (err) { - console.error('Error getting answers:', err); + console.error('Error getting answer:', err); return c.json({ error: 'Internal server error' }, 500); } }); diff --git a/src/storage/d1.ts b/src/storage/d1.ts index b6699fc..99403c5 100644 --- a/src/storage/d1.ts +++ b/src/storage/d1.ts @@ -401,6 +401,7 @@ export class D1Storage implements Storage { async createService(request: CreateServiceRequest): Promise<{ service: Service; indexUuid: string; + offers: Offer[]; }> { const serviceId = crypto.randomUUID(); const indexUuid = crypto.randomUUID(); @@ -408,13 +409,12 @@ export class D1Storage implements Storage { // Insert service await this.db.prepare(` - INSERT INTO services (id, username, service_fqn, offer_id, created_at, expires_at, is_public, metadata) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO services (id, username, service_fqn, created_at, expires_at, is_public, metadata) + VALUES (?, ?, ?, ?, ?, ?, ?) `).bind( serviceId, request.username, request.serviceFqn, - request.offerId, now, request.expiresAt, request.isPublic ? 1 : 0, @@ -434,6 +434,13 @@ export class D1Storage implements Storage { request.expiresAt ).run(); + // Create offers with serviceId + const offerRequests = request.offers.map(offer => ({ + ...offer, + serviceId, + })); + const offers = await this.createOffers(offerRequests); + // Touch username to extend expiry await this.touchUsername(request.username); @@ -442,16 +449,43 @@ export class D1Storage implements Storage { id: serviceId, username: request.username, serviceFqn: request.serviceFqn, - offerId: request.offerId, createdAt: now, expiresAt: request.expiresAt, isPublic: request.isPublic || false, metadata: request.metadata, }, indexUuid, + offers, }; } + async batchCreateServices(requests: CreateServiceRequest[]): Promise> { + const results = []; + for (const request of requests) { + const result = await this.createService(request); + results.push(result); + } + return results; + } + + async getOffersForService(serviceId: string): Promise { + const result = await this.db.prepare(` + SELECT * FROM offers + WHERE service_id = ? AND expires_at > ? + ORDER BY created_at ASC + `).bind(serviceId, Date.now()).all(); + + if (!result.results) { + return []; + } + + return result.results.map(row => this.rowToOffer(row as any)); + } + async getServiceById(serviceId: string): Promise { const result = await this.db.prepare(` SELECT * FROM services @@ -560,7 +594,6 @@ export class D1Storage implements Storage { id: row.id, username: row.username, serviceFqn: row.service_fqn, - offerId: row.offer_id, createdAt: row.created_at, expiresAt: row.expires_at, isPublic: row.is_public === 1, diff --git a/src/storage/sqlite.ts b/src/storage/sqlite.ts index fce4b84..058037e 100644 --- a/src/storage/sqlite.ts +++ b/src/storage/sqlite.ts @@ -40,6 +40,7 @@ export class SQLiteStorage implements Storage { CREATE TABLE IF NOT EXISTS offers ( id TEXT PRIMARY KEY, peer_id TEXT NOT NULL, + service_id TEXT, sdp TEXT NOT NULL, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, @@ -47,10 +48,12 @@ export class SQLiteStorage implements Storage { secret TEXT, answerer_peer_id TEXT, answer_sdp TEXT, - answered_at INTEGER + answered_at INTEGER, + FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_offers_peer ON offers(peer_id); + CREATE INDEX IF NOT EXISTS idx_offers_service ON offers(service_id); CREATE INDEX IF NOT EXISTS idx_offers_expires ON offers(expires_at); CREATE INDEX IF NOT EXISTS idx_offers_last_seen ON offers(last_seen); CREATE INDEX IF NOT EXISTS idx_offers_answerer ON offers(answerer_peer_id); @@ -84,25 +87,22 @@ export class SQLiteStorage implements Storage { CREATE INDEX IF NOT EXISTS idx_usernames_expires ON usernames(expires_at); CREATE INDEX IF NOT EXISTS idx_usernames_public_key ON usernames(public_key); - -- Services table + -- Services table (one service can have multiple offers) CREATE TABLE IF NOT EXISTS services ( id TEXT PRIMARY KEY, username TEXT NOT NULL, service_fqn TEXT NOT NULL, - offer_id TEXT NOT NULL, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, is_public INTEGER NOT NULL DEFAULT 0, metadata TEXT, FOREIGN KEY (username) REFERENCES usernames(username) ON DELETE CASCADE, - FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE, UNIQUE(username, service_fqn) ); CREATE INDEX IF NOT EXISTS idx_services_username ON services(username); CREATE INDEX IF NOT EXISTS idx_services_fqn ON services(service_fqn); CREATE INDEX IF NOT EXISTS idx_services_expires ON services(expires_at); - CREATE INDEX IF NOT EXISTS idx_services_offer ON services(offer_id); -- Service index table (privacy layer) CREATE TABLE IF NOT EXISTS service_index ( @@ -139,8 +139,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, secret) - VALUES (?, ?, ?, ?, ?, ?, ?) + INSERT INTO offers (id, peer_id, service_id, sdp, created_at, expires_at, last_seen, secret) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) `); for (const offer of offersWithIds) { @@ -150,6 +150,7 @@ export class SQLiteStorage implements Storage { offerStmt.run( offer.id, offer.peerId, + offer.serviceId || null, offer.sdp, now, offer.expiresAt, @@ -160,6 +161,7 @@ export class SQLiteStorage implements Storage { created.push({ id: offer.id, peerId: offer.peerId, + serviceId: offer.serviceId || undefined, sdp: offer.sdp, createdAt: now, expiresAt: offer.expiresAt, @@ -426,23 +428,31 @@ export class SQLiteStorage implements Storage { async createService(request: CreateServiceRequest): Promise<{ service: Service; indexUuid: string; + offers: Offer[]; }> { const serviceId = randomUUID(); const indexUuid = randomUUID(); const now = Date.now(); + // Create offers with serviceId + const offerRequests: CreateOfferRequest[] = request.offers.map(offer => ({ + ...offer, + serviceId, + })); + + const offers = await this.createOffers(offerRequests); + const transaction = this.db.transaction(() => { - // Insert service + // Insert service (no offer_id column anymore) const serviceStmt = this.db.prepare(` - INSERT INTO services (id, username, service_fqn, offer_id, created_at, expires_at, is_public, metadata) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO services (id, username, service_fqn, created_at, expires_at, is_public, metadata) + VALUES (?, ?, ?, ?, ?, ?, ?) `); serviceStmt.run( serviceId, request.username, request.serviceFqn, - request.offerId, now, request.expiresAt, request.isPublic ? 1 : 0, @@ -475,16 +485,31 @@ export class SQLiteStorage implements Storage { id: serviceId, username: request.username, serviceFqn: request.serviceFqn, - offerId: request.offerId, createdAt: now, expiresAt: request.expiresAt, isPublic: request.isPublic || false, metadata: request.metadata, }, indexUuid, + offers, }; } + async batchCreateServices(requests: CreateServiceRequest[]): Promise> { + const results = []; + + for (const request of requests) { + const result = await this.createService(request); + results.push(result); + } + + return results; + } + async getServiceById(serviceId: string): Promise { const stmt = this.db.prepare(` SELECT * FROM services @@ -576,6 +601,7 @@ export class SQLiteStorage implements Storage { return { id: row.id, peerId: row.peer_id, + serviceId: row.service_id || undefined, sdp: row.sdp, createdAt: row.created_at, expiresAt: row.expires_at, @@ -595,11 +621,24 @@ export class SQLiteStorage implements Storage { id: row.id, username: row.username, serviceFqn: row.service_fqn, - offerId: row.offer_id, createdAt: row.created_at, expiresAt: row.expires_at, isPublic: row.is_public === 1, metadata: row.metadata || undefined, }; } + + /** + * Get all offers for a service + */ + async getOffersForService(serviceId: string): Promise { + const stmt = this.db.prepare(` + SELECT * FROM offers + WHERE service_id = ? AND expires_at > ? + ORDER BY created_at ASC + `); + + const rows = stmt.all(serviceId, Date.now()) as any[]; + return rows.map(row => this.rowToOffer(row)); + } } diff --git a/src/storage/types.ts b/src/storage/types.ts index 8dd0950..086ae62 100644 --- a/src/storage/types.ts +++ b/src/storage/types.ts @@ -4,6 +4,7 @@ export interface Offer { id: string; peerId: string; + serviceId?: string; // Optional link to service (null for standalone offers) sdp: string; createdAt: number; expiresAt: number; @@ -33,6 +34,7 @@ export interface IceCandidate { export interface CreateOfferRequest { id?: string; peerId: string; + serviceId?: string; // Optional link to service sdp: string; expiresAt: number; secret?: string; @@ -61,13 +63,12 @@ export interface ClaimUsernameRequest { } /** - * Represents a published service + * Represents a published service (can have multiple offers) */ export interface Service { id: string; // UUID v4 username: string; serviceFqn: string; // com.example.chat@1.0.0 - offerId: string; // Links to offers table createdAt: number; expiresAt: number; isPublic: boolean; @@ -75,15 +76,22 @@ export interface Service { } /** - * Request to create a service + * Request to create a single service */ export interface CreateServiceRequest { username: string; serviceFqn: string; - offerId: string; expiresAt: number; isPublic?: boolean; metadata?: string; + offers: CreateOfferRequest[]; // Multiple offers per service +} + +/** + * Request to create multiple services in batch + */ +export interface BatchCreateServicesRequest { + services: CreateServiceRequest[]; } /** @@ -234,15 +242,34 @@ export interface Storage { // ===== Service Management ===== /** - * Creates a new service - * @param request Service creation request - * @returns Created service with generated ID and index UUID + * Creates a new service with offers + * @param request Service creation request (includes offers) + * @returns Created service with generated ID, index UUID, and created offers */ createService(request: CreateServiceRequest): Promise<{ service: Service; indexUuid: string; + offers: Offer[]; }>; + /** + * Creates multiple services with offers in batch + * @param requests Array of service creation requests + * @returns Array of created services with IDs, UUIDs, and offers + */ + batchCreateServices(requests: CreateServiceRequest[]): Promise>; + + /** + * Gets all offers for a service + * @param serviceId Service ID + * @returns Array of offers for the service + */ + getOffersForService(serviceId: string): Promise; + /** * Gets a service by its service ID * @param serviceId Service ID