@xtr-dev/rondevu-server 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ADVANCED.md +502 -0
- package/README.md +139 -251
- package/dist/index.js +715 -770
- package/dist/index.js.map +4 -4
- package/migrations/0006_service_offer_refactor.sql +40 -0
- package/migrations/0007_simplify_schema.sql +54 -0
- package/migrations/0008_peer_id_to_username.sql +67 -0
- package/migrations/fresh_schema.sql +81 -0
- package/package.json +2 -1
- package/src/app.ts +38 -677
- package/src/config.ts +0 -13
- package/src/crypto.ts +98 -133
- package/src/rpc.ts +725 -0
- package/src/storage/d1.ts +169 -182
- package/src/storage/sqlite.ts +142 -168
- package/src/storage/types.ts +51 -95
- package/src/worker.ts +0 -6
- package/wrangler.toml +3 -3
- package/src/middleware/auth.ts +0 -51
package/src/app.ts
CHANGED
|
@@ -2,20 +2,17 @@ import { Hono } from 'hono';
|
|
|
2
2
|
import { cors } from 'hono/cors';
|
|
3
3
|
import { Storage } from './storage/types.ts';
|
|
4
4
|
import { Config } from './config.ts';
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
import { handleRpc, RpcRequest } from './rpc.ts';
|
|
6
|
+
|
|
7
|
+
// Constants
|
|
8
|
+
const MAX_BATCH_SIZE = 100;
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
|
-
* Creates the Hono application with
|
|
11
|
-
* RESTful API design - v0.11.0
|
|
11
|
+
* Creates the Hono application with RPC interface
|
|
12
12
|
*/
|
|
13
13
|
export function createApp(storage: Storage, config: Config) {
|
|
14
14
|
const app = new Hono();
|
|
15
15
|
|
|
16
|
-
// Create auth middleware
|
|
17
|
-
const authMiddleware = createAuthMiddleware(config.authSecret);
|
|
18
|
-
|
|
19
16
|
// Enable CORS
|
|
20
17
|
app.use('/*', cors({
|
|
21
18
|
origin: (origin) => {
|
|
@@ -27,706 +24,70 @@ export function createApp(storage: Storage, config: Config) {
|
|
|
27
24
|
}
|
|
28
25
|
return config.corsOrigins[0];
|
|
29
26
|
},
|
|
30
|
-
allowMethods: ['GET', 'POST', '
|
|
31
|
-
allowHeaders: ['Content-Type', 'Origin'
|
|
27
|
+
allowMethods: ['GET', 'POST', 'OPTIONS'],
|
|
28
|
+
allowHeaders: ['Content-Type', 'Origin'],
|
|
32
29
|
exposeHeaders: ['Content-Type'],
|
|
33
|
-
|
|
34
|
-
|
|
30
|
+
credentials: false,
|
|
31
|
+
maxAge: 86400,
|
|
35
32
|
}));
|
|
36
33
|
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* GET /
|
|
41
|
-
* Returns server information
|
|
42
|
-
*/
|
|
34
|
+
// Root endpoint - server info
|
|
43
35
|
app.get('/', (c) => {
|
|
44
36
|
return c.json({
|
|
45
37
|
version: config.version,
|
|
46
38
|
name: 'Rondevu',
|
|
47
|
-
description: '
|
|
48
|
-
});
|
|
39
|
+
description: 'WebRTC signaling with RPC interface and Ed25519 authentication',
|
|
40
|
+
}, 200);
|
|
49
41
|
});
|
|
50
42
|
|
|
51
|
-
|
|
52
|
-
* GET /health
|
|
53
|
-
* Health check endpoint
|
|
54
|
-
*/
|
|
43
|
+
// Health check
|
|
55
44
|
app.get('/health', (c) => {
|
|
56
45
|
return c.json({
|
|
57
46
|
status: 'ok',
|
|
58
47
|
timestamp: Date.now(),
|
|
59
|
-
version: config.version
|
|
60
|
-
});
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* POST /register
|
|
65
|
-
* Register a new peer
|
|
66
|
-
*/
|
|
67
|
-
app.post('/register', async (c) => {
|
|
68
|
-
try {
|
|
69
|
-
const peerId = generatePeerId();
|
|
70
|
-
const secret = await encryptPeerId(peerId, config.authSecret);
|
|
71
|
-
|
|
72
|
-
return c.json({
|
|
73
|
-
peerId,
|
|
74
|
-
secret
|
|
75
|
-
}, 200);
|
|
76
|
-
} catch (err) {
|
|
77
|
-
console.error('Error registering peer:', err);
|
|
78
|
-
return c.json({ error: 'Internal server error' }, 500);
|
|
79
|
-
}
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
// ===== User Management (RESTful) =====
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* GET /users/:username
|
|
86
|
-
* Check if username is available or get claim info
|
|
87
|
-
*/
|
|
88
|
-
app.get('/users/:username', async (c) => {
|
|
89
|
-
try {
|
|
90
|
-
const username = c.req.param('username');
|
|
91
|
-
|
|
92
|
-
const claimed = await storage.getUsername(username);
|
|
93
|
-
|
|
94
|
-
if (!claimed) {
|
|
95
|
-
return c.json({
|
|
96
|
-
username,
|
|
97
|
-
available: true
|
|
98
|
-
}, 200);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
return c.json({
|
|
102
|
-
username: claimed.username,
|
|
103
|
-
available: false,
|
|
104
|
-
claimedAt: claimed.claimedAt,
|
|
105
|
-
expiresAt: claimed.expiresAt,
|
|
106
|
-
publicKey: claimed.publicKey
|
|
107
|
-
}, 200);
|
|
108
|
-
} catch (err) {
|
|
109
|
-
console.error('Error checking username:', err);
|
|
110
|
-
return c.json({ error: 'Internal server error' }, 500);
|
|
111
|
-
}
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* POST /users/:username
|
|
116
|
-
* Claim a username with cryptographic proof
|
|
117
|
-
*/
|
|
118
|
-
app.post('/users/:username', async (c) => {
|
|
119
|
-
try {
|
|
120
|
-
const username = c.req.param('username');
|
|
121
|
-
const body = await c.req.json();
|
|
122
|
-
const { publicKey, signature, message } = body;
|
|
123
|
-
|
|
124
|
-
if (!publicKey || !signature || !message) {
|
|
125
|
-
return c.json({ error: 'Missing required parameters: publicKey, signature, message' }, 400);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// Validate claim
|
|
129
|
-
const validation = await validateUsernameClaim(username, publicKey, signature, message);
|
|
130
|
-
if (!validation.valid) {
|
|
131
|
-
return c.json({ error: validation.error }, 400);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// Attempt to claim username
|
|
135
|
-
try {
|
|
136
|
-
const claimed = await storage.claimUsername({
|
|
137
|
-
username,
|
|
138
|
-
publicKey,
|
|
139
|
-
signature,
|
|
140
|
-
message
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
return c.json({
|
|
144
|
-
username: claimed.username,
|
|
145
|
-
claimedAt: claimed.claimedAt,
|
|
146
|
-
expiresAt: claimed.expiresAt
|
|
147
|
-
}, 201);
|
|
148
|
-
} catch (err: any) {
|
|
149
|
-
if (err.message?.includes('already claimed')) {
|
|
150
|
-
return c.json({ error: 'Username already claimed by different public key' }, 409);
|
|
151
|
-
}
|
|
152
|
-
throw err;
|
|
153
|
-
}
|
|
154
|
-
} catch (err) {
|
|
155
|
-
console.error('Error claiming username:', err);
|
|
156
|
-
return c.json({ error: 'Internal server error' }, 500);
|
|
157
|
-
}
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* GET /users/:username/services/:fqn
|
|
162
|
-
* Get service by username and FQN with semver-compatible matching
|
|
163
|
-
*/
|
|
164
|
-
app.get('/users/:username/services/:fqn', async (c) => {
|
|
165
|
-
try {
|
|
166
|
-
const username = c.req.param('username');
|
|
167
|
-
const serviceFqn = decodeURIComponent(c.req.param('fqn'));
|
|
168
|
-
|
|
169
|
-
// Parse the requested FQN
|
|
170
|
-
const parsed = parseServiceFqn(serviceFqn);
|
|
171
|
-
if (!parsed) {
|
|
172
|
-
return c.json({ error: 'Invalid service FQN format' }, 400);
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
const { serviceName, version: requestedVersion } = parsed;
|
|
176
|
-
|
|
177
|
-
// Find all services with matching service name
|
|
178
|
-
const matchingServices = await storage.findServicesByName(username, serviceName);
|
|
179
|
-
|
|
180
|
-
if (matchingServices.length === 0) {
|
|
181
|
-
return c.json({ error: 'Service not found' }, 404);
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
// Filter to compatible versions
|
|
185
|
-
const compatibleServices = matchingServices.filter(service => {
|
|
186
|
-
const serviceParsed = parseServiceFqn(service.serviceFqn);
|
|
187
|
-
if (!serviceParsed) return false;
|
|
188
|
-
return isVersionCompatible(requestedVersion, serviceParsed.version);
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
if (compatibleServices.length === 0) {
|
|
192
|
-
return c.json({
|
|
193
|
-
error: 'No compatible version found',
|
|
194
|
-
message: `Requested ${serviceFqn}, but no compatible versions available`
|
|
195
|
-
}, 404);
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// Use the first compatible service (most recently created)
|
|
199
|
-
const service = compatibleServices[0];
|
|
200
|
-
|
|
201
|
-
// Get the UUID for this service
|
|
202
|
-
const uuid = await storage.queryService(username, service.serviceFqn);
|
|
203
|
-
|
|
204
|
-
if (!uuid) {
|
|
205
|
-
return c.json({ error: 'Service index not found' }, 500);
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
// Get all offers for this service
|
|
209
|
-
const serviceOffers = await storage.getOffersForService(service.id);
|
|
210
|
-
|
|
211
|
-
if (serviceOffers.length === 0) {
|
|
212
|
-
return c.json({ error: 'No offers found for this service' }, 404);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// Find an unanswered offer
|
|
216
|
-
const availableOffer = serviceOffers.find(offer => !offer.answererPeerId);
|
|
217
|
-
|
|
218
|
-
if (!availableOffer) {
|
|
219
|
-
return c.json({
|
|
220
|
-
error: 'No available offers',
|
|
221
|
-
message: 'All offers from this service are currently in use. Please try again later.'
|
|
222
|
-
}, 503);
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
return c.json({
|
|
226
|
-
uuid: uuid,
|
|
227
|
-
serviceId: service.id,
|
|
228
|
-
username: service.username,
|
|
229
|
-
serviceFqn: service.serviceFqn,
|
|
230
|
-
offerId: availableOffer.id,
|
|
231
|
-
sdp: availableOffer.sdp,
|
|
232
|
-
isPublic: service.isPublic,
|
|
233
|
-
metadata: service.metadata ? JSON.parse(service.metadata) : undefined,
|
|
234
|
-
createdAt: service.createdAt,
|
|
235
|
-
expiresAt: service.expiresAt
|
|
236
|
-
}, 200);
|
|
237
|
-
} catch (err) {
|
|
238
|
-
console.error('Error getting service:', err);
|
|
239
|
-
return c.json({ error: 'Internal server error' }, 500);
|
|
240
|
-
}
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
/**
|
|
244
|
-
* POST /users/:username/services
|
|
245
|
-
* Publish a service with one or more offers (RESTful endpoint)
|
|
246
|
-
*/
|
|
247
|
-
app.post('/users/:username/services', authMiddleware, async (c) => {
|
|
248
|
-
let serviceFqn: string | undefined;
|
|
249
|
-
let createdOffers: any[] = [];
|
|
250
|
-
|
|
251
|
-
try {
|
|
252
|
-
const username = c.req.param('username');
|
|
253
|
-
const body = await c.req.json();
|
|
254
|
-
serviceFqn = body.serviceFqn;
|
|
255
|
-
const { offers, ttl, isPublic, metadata, signature, message } = body;
|
|
256
|
-
|
|
257
|
-
if (!serviceFqn || !offers || !Array.isArray(offers) || offers.length === 0) {
|
|
258
|
-
return c.json({ error: 'Missing required parameters: serviceFqn, offers (must be non-empty array)' }, 400);
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
// Validate service FQN
|
|
262
|
-
const fqnValidation = validateServiceFqn(serviceFqn);
|
|
263
|
-
if (!fqnValidation.valid) {
|
|
264
|
-
return c.json({ error: fqnValidation.error }, 400);
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
// Verify username ownership (signature required)
|
|
268
|
-
if (!signature || !message) {
|
|
269
|
-
return c.json({ error: 'Missing signature or message for username verification' }, 400);
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
const usernameRecord = await storage.getUsername(username);
|
|
273
|
-
if (!usernameRecord) {
|
|
274
|
-
return c.json({ error: 'Username not claimed' }, 404);
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
// Verify signature matches username's public key
|
|
278
|
-
const signatureValidation = await validateServicePublish(username, serviceFqn, usernameRecord.publicKey, signature, message);
|
|
279
|
-
if (!signatureValidation.valid) {
|
|
280
|
-
return c.json({ error: 'Invalid signature for username' }, 403);
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
// Delete existing service if one exists (upsert behavior)
|
|
284
|
-
const existingUuid = await storage.queryService(username, serviceFqn);
|
|
285
|
-
if (existingUuid) {
|
|
286
|
-
const existingService = await storage.getServiceByUuid(existingUuid);
|
|
287
|
-
if (existingService) {
|
|
288
|
-
await storage.deleteService(existingService.id, username);
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
// Validate all offers
|
|
293
|
-
for (const offer of offers) {
|
|
294
|
-
if (!offer.sdp || typeof offer.sdp !== 'string' || offer.sdp.length === 0) {
|
|
295
|
-
return c.json({ error: 'Invalid SDP in offers array' }, 400);
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
if (offer.sdp.length > 64 * 1024) {
|
|
299
|
-
return c.json({ error: 'SDP too large (max 64KB)' }, 400);
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
// Calculate expiry
|
|
304
|
-
const peerId = getAuthenticatedPeerId(c);
|
|
305
|
-
const offerTtl = Math.min(
|
|
306
|
-
Math.max(ttl || config.offerDefaultTtl, config.offerMinTtl),
|
|
307
|
-
config.offerMaxTtl
|
|
308
|
-
);
|
|
309
|
-
const expiresAt = Date.now() + offerTtl;
|
|
310
|
-
|
|
311
|
-
// Prepare offer requests
|
|
312
|
-
const offerRequests = offers.map(offer => ({
|
|
313
|
-
peerId,
|
|
314
|
-
sdp: offer.sdp,
|
|
315
|
-
expiresAt
|
|
316
|
-
}));
|
|
317
|
-
|
|
318
|
-
// Create service with offers
|
|
319
|
-
const result = await storage.createService({
|
|
320
|
-
username,
|
|
321
|
-
serviceFqn,
|
|
322
|
-
expiresAt,
|
|
323
|
-
isPublic: isPublic || false,
|
|
324
|
-
metadata: metadata ? JSON.stringify(metadata) : undefined,
|
|
325
|
-
offers: offerRequests
|
|
326
|
-
});
|
|
327
|
-
|
|
328
|
-
createdOffers = result.offers;
|
|
329
|
-
|
|
330
|
-
// Return full service details with all offers
|
|
331
|
-
return c.json({
|
|
332
|
-
uuid: result.indexUuid,
|
|
333
|
-
serviceFqn: serviceFqn,
|
|
334
|
-
username: username,
|
|
335
|
-
serviceId: result.service.id,
|
|
336
|
-
offers: result.offers.map(o => ({
|
|
337
|
-
offerId: o.id,
|
|
338
|
-
sdp: o.sdp,
|
|
339
|
-
createdAt: o.createdAt,
|
|
340
|
-
expiresAt: o.expiresAt
|
|
341
|
-
})),
|
|
342
|
-
isPublic: result.service.isPublic,
|
|
343
|
-
metadata: metadata,
|
|
344
|
-
createdAt: result.service.createdAt,
|
|
345
|
-
expiresAt: result.service.expiresAt
|
|
346
|
-
}, 201);
|
|
347
|
-
} catch (err) {
|
|
348
|
-
console.error('Error creating service:', err);
|
|
349
|
-
console.error('Error details:', {
|
|
350
|
-
message: (err as Error).message,
|
|
351
|
-
stack: (err as Error).stack,
|
|
352
|
-
username: c.req.param('username'),
|
|
353
|
-
serviceFqn,
|
|
354
|
-
offerIds: createdOffers.map(o => o.id)
|
|
355
|
-
});
|
|
356
|
-
return c.json({
|
|
357
|
-
error: 'Internal server error',
|
|
358
|
-
details: (err as Error).message
|
|
359
|
-
}, 500);
|
|
360
|
-
}
|
|
361
|
-
});
|
|
362
|
-
|
|
363
|
-
/**
|
|
364
|
-
* DELETE /users/:username/services/:fqn
|
|
365
|
-
* Delete a service by username and FQN (RESTful)
|
|
366
|
-
*/
|
|
367
|
-
app.delete('/users/:username/services/:fqn', authMiddleware, async (c) => {
|
|
368
|
-
try {
|
|
369
|
-
const username = c.req.param('username');
|
|
370
|
-
const serviceFqn = decodeURIComponent(c.req.param('fqn'));
|
|
371
|
-
|
|
372
|
-
// Find service by username and FQN
|
|
373
|
-
const uuid = await storage.queryService(username, serviceFqn);
|
|
374
|
-
if (!uuid) {
|
|
375
|
-
return c.json({ error: 'Service not found' }, 404);
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
const service = await storage.getServiceByUuid(uuid);
|
|
379
|
-
if (!service) {
|
|
380
|
-
return c.json({ error: 'Service not found' }, 404);
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
const deleted = await storage.deleteService(service.id, username);
|
|
384
|
-
|
|
385
|
-
if (!deleted) {
|
|
386
|
-
return c.json({ error: 'Service not found or not owned by this username' }, 404);
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
return c.json({ success: true }, 200);
|
|
390
|
-
} catch (err) {
|
|
391
|
-
console.error('Error deleting service:', err);
|
|
392
|
-
return c.json({ error: 'Internal server error' }, 500);
|
|
393
|
-
}
|
|
394
|
-
});
|
|
395
|
-
|
|
396
|
-
// ===== Service Management (Legacy - for UUID-based access) =====
|
|
397
|
-
|
|
398
|
-
/**
|
|
399
|
-
* GET /services/:uuid
|
|
400
|
-
* Get service details by index UUID (kept for privacy)
|
|
401
|
-
*/
|
|
402
|
-
app.get('/services/:uuid', async (c) => {
|
|
403
|
-
try {
|
|
404
|
-
const uuid = c.req.param('uuid');
|
|
405
|
-
|
|
406
|
-
const service = await storage.getServiceByUuid(uuid);
|
|
407
|
-
|
|
408
|
-
if (!service) {
|
|
409
|
-
return c.json({ error: 'Service not found' }, 404);
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
// Get all offers for this service
|
|
413
|
-
const serviceOffers = await storage.getOffersForService(service.id);
|
|
414
|
-
|
|
415
|
-
if (serviceOffers.length === 0) {
|
|
416
|
-
return c.json({ error: 'No offers found for this service' }, 404);
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
// Find an unanswered offer
|
|
420
|
-
const availableOffer = serviceOffers.find(offer => !offer.answererPeerId);
|
|
421
|
-
|
|
422
|
-
if (!availableOffer) {
|
|
423
|
-
return c.json({
|
|
424
|
-
error: 'No available offers',
|
|
425
|
-
message: 'All offers from this service are currently in use. Please try again later.'
|
|
426
|
-
}, 503);
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
return c.json({
|
|
430
|
-
uuid: uuid,
|
|
431
|
-
serviceId: service.id,
|
|
432
|
-
username: service.username,
|
|
433
|
-
serviceFqn: service.serviceFqn,
|
|
434
|
-
offerId: availableOffer.id,
|
|
435
|
-
sdp: availableOffer.sdp,
|
|
436
|
-
isPublic: service.isPublic,
|
|
437
|
-
metadata: service.metadata ? JSON.parse(service.metadata) : undefined,
|
|
438
|
-
createdAt: service.createdAt,
|
|
439
|
-
expiresAt: service.expiresAt
|
|
440
|
-
}, 200);
|
|
441
|
-
} catch (err) {
|
|
442
|
-
console.error('Error getting service:', err);
|
|
443
|
-
return c.json({ error: 'Internal server error' }, 500);
|
|
444
|
-
}
|
|
445
|
-
});
|
|
446
|
-
|
|
447
|
-
// ===== Offer Management (Core WebRTC) =====
|
|
448
|
-
|
|
449
|
-
/**
|
|
450
|
-
* POST /offers
|
|
451
|
-
* Create offers (direct, no service - for testing/advanced users)
|
|
452
|
-
*/
|
|
453
|
-
app.post('/offers', authMiddleware, async (c) => {
|
|
454
|
-
try {
|
|
455
|
-
const body = await c.req.json();
|
|
456
|
-
const { offers } = body;
|
|
457
|
-
|
|
458
|
-
if (!Array.isArray(offers) || offers.length === 0) {
|
|
459
|
-
return c.json({ error: 'Missing or invalid required parameter: offers (must be non-empty array)' }, 400);
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
if (offers.length > config.maxOffersPerRequest) {
|
|
463
|
-
return c.json({ error: `Too many offers (max ${config.maxOffersPerRequest})` }, 400);
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
const peerId = getAuthenticatedPeerId(c);
|
|
467
|
-
|
|
468
|
-
// Validate and prepare offers
|
|
469
|
-
const validated = offers.map((offer: any) => {
|
|
470
|
-
const { sdp, ttl, secret } = offer;
|
|
471
|
-
|
|
472
|
-
if (typeof sdp !== 'string' || sdp.length === 0) {
|
|
473
|
-
throw new Error('Invalid SDP in offer');
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
if (sdp.length > 64 * 1024) {
|
|
477
|
-
throw new Error('SDP too large (max 64KB)');
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
const offerTtl = Math.min(
|
|
481
|
-
Math.max(ttl || config.offerDefaultTtl, config.offerMinTtl),
|
|
482
|
-
config.offerMaxTtl
|
|
483
|
-
);
|
|
484
|
-
|
|
485
|
-
return {
|
|
486
|
-
peerId,
|
|
487
|
-
sdp,
|
|
488
|
-
expiresAt: Date.now() + offerTtl,
|
|
489
|
-
secret: secret ? String(secret).substring(0, 128) : undefined
|
|
490
|
-
};
|
|
491
|
-
});
|
|
492
|
-
|
|
493
|
-
const created = await storage.createOffers(validated);
|
|
494
|
-
|
|
495
|
-
return c.json({
|
|
496
|
-
offers: created.map(offer => ({
|
|
497
|
-
id: offer.id,
|
|
498
|
-
peerId: offer.peerId,
|
|
499
|
-
expiresAt: offer.expiresAt,
|
|
500
|
-
createdAt: offer.createdAt,
|
|
501
|
-
hasSecret: !!offer.secret
|
|
502
|
-
}))
|
|
503
|
-
}, 201);
|
|
504
|
-
} catch (err: any) {
|
|
505
|
-
console.error('Error creating offers:', err);
|
|
506
|
-
return c.json({ error: err.message || 'Internal server error' }, 500);
|
|
507
|
-
}
|
|
508
|
-
});
|
|
509
|
-
|
|
510
|
-
/**
|
|
511
|
-
* GET /offers/mine
|
|
512
|
-
* Get authenticated peer's offers
|
|
513
|
-
*/
|
|
514
|
-
app.get('/offers/mine', authMiddleware, async (c) => {
|
|
515
|
-
try {
|
|
516
|
-
const peerId = getAuthenticatedPeerId(c);
|
|
517
|
-
const offers = await storage.getOffersByPeerId(peerId);
|
|
518
|
-
|
|
519
|
-
return c.json({
|
|
520
|
-
offers: offers.map(offer => ({
|
|
521
|
-
id: offer.id,
|
|
522
|
-
sdp: offer.sdp,
|
|
523
|
-
createdAt: offer.createdAt,
|
|
524
|
-
expiresAt: offer.expiresAt,
|
|
525
|
-
lastSeen: offer.lastSeen,
|
|
526
|
-
hasSecret: !!offer.secret,
|
|
527
|
-
answererPeerId: offer.answererPeerId,
|
|
528
|
-
answered: !!offer.answererPeerId
|
|
529
|
-
}))
|
|
530
|
-
}, 200);
|
|
531
|
-
} catch (err) {
|
|
532
|
-
console.error('Error getting offers:', err);
|
|
533
|
-
return c.json({ error: 'Internal server error' }, 500);
|
|
534
|
-
}
|
|
535
|
-
});
|
|
536
|
-
|
|
537
|
-
/**
|
|
538
|
-
* GET /offers/:offerId
|
|
539
|
-
* Get offer details (added for completeness)
|
|
540
|
-
*/
|
|
541
|
-
app.get('/offers/:offerId', authMiddleware, async (c) => {
|
|
542
|
-
try {
|
|
543
|
-
const offerId = c.req.param('offerId');
|
|
544
|
-
const offer = await storage.getOfferById(offerId);
|
|
545
|
-
|
|
546
|
-
if (!offer) {
|
|
547
|
-
return c.json({ error: 'Offer not found' }, 404);
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
return c.json({
|
|
551
|
-
id: offer.id,
|
|
552
|
-
peerId: offer.peerId,
|
|
553
|
-
sdp: offer.sdp,
|
|
554
|
-
createdAt: offer.createdAt,
|
|
555
|
-
expiresAt: offer.expiresAt,
|
|
556
|
-
answererPeerId: offer.answererPeerId,
|
|
557
|
-
answered: !!offer.answererPeerId,
|
|
558
|
-
answerSdp: offer.answerSdp
|
|
559
|
-
}, 200);
|
|
560
|
-
} catch (err) {
|
|
561
|
-
console.error('Error getting offer:', err);
|
|
562
|
-
return c.json({ error: 'Internal server error' }, 500);
|
|
563
|
-
}
|
|
564
|
-
});
|
|
565
|
-
|
|
566
|
-
/**
|
|
567
|
-
* DELETE /offers/:offerId
|
|
568
|
-
* Delete an offer
|
|
569
|
-
*/
|
|
570
|
-
app.delete('/offers/:offerId', authMiddleware, async (c) => {
|
|
571
|
-
try {
|
|
572
|
-
const offerId = c.req.param('offerId');
|
|
573
|
-
const peerId = getAuthenticatedPeerId(c);
|
|
574
|
-
|
|
575
|
-
const deleted = await storage.deleteOffer(offerId, peerId);
|
|
576
|
-
|
|
577
|
-
if (!deleted) {
|
|
578
|
-
return c.json({ error: 'Offer not found or not owned by this peer' }, 404);
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
return c.json({ success: true }, 200);
|
|
582
|
-
} catch (err) {
|
|
583
|
-
console.error('Error deleting offer:', err);
|
|
584
|
-
return c.json({ error: 'Internal server error' }, 500);
|
|
585
|
-
}
|
|
48
|
+
version: config.version,
|
|
49
|
+
}, 200);
|
|
586
50
|
});
|
|
587
51
|
|
|
588
52
|
/**
|
|
589
|
-
* POST /
|
|
590
|
-
*
|
|
53
|
+
* POST /rpc
|
|
54
|
+
* RPC endpoint - accepts single or batch method calls
|
|
591
55
|
*/
|
|
592
|
-
app.post('/
|
|
56
|
+
app.post('/rpc', async (c) => {
|
|
593
57
|
try {
|
|
594
|
-
const offerId = c.req.param('offerId');
|
|
595
58
|
const body = await c.req.json();
|
|
596
|
-
const { sdp, secret } = body;
|
|
597
59
|
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
}
|
|
60
|
+
// Support both single request and batch array
|
|
61
|
+
const requests: RpcRequest[] = Array.isArray(body) ? body : [body];
|
|
601
62
|
|
|
602
|
-
|
|
603
|
-
|
|
63
|
+
// Validate requests
|
|
64
|
+
if (requests.length === 0) {
|
|
65
|
+
return c.json({ error: 'Empty request array' }, 400);
|
|
604
66
|
}
|
|
605
67
|
|
|
606
|
-
if (
|
|
607
|
-
return c.json({ error:
|
|
68
|
+
if (requests.length > MAX_BATCH_SIZE) {
|
|
69
|
+
return c.json({ error: `Too many requests in batch (max ${MAX_BATCH_SIZE})` }, 400);
|
|
608
70
|
}
|
|
609
71
|
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
const result = await storage.answerOffer(offerId, answererPeerId, sdp, secret);
|
|
613
|
-
|
|
614
|
-
if (!result.success) {
|
|
615
|
-
return c.json({ error: result.error }, 400);
|
|
616
|
-
}
|
|
72
|
+
// Handle RPC
|
|
73
|
+
const responses = await handleRpc(requests, storage, config);
|
|
617
74
|
|
|
618
|
-
|
|
75
|
+
// Return single response or array based on input
|
|
76
|
+
return c.json(Array.isArray(body) ? responses : responses[0], 200);
|
|
619
77
|
} catch (err) {
|
|
620
|
-
console.error('
|
|
621
|
-
return c.json({ error: 'Internal server error' }, 500);
|
|
622
|
-
}
|
|
623
|
-
});
|
|
624
|
-
|
|
625
|
-
/**
|
|
626
|
-
* GET /offers/:offerId/answer
|
|
627
|
-
* Get answer for a specific offer (RESTful endpoint)
|
|
628
|
-
*/
|
|
629
|
-
app.get('/offers/:offerId/answer', authMiddleware, async (c) => {
|
|
630
|
-
try {
|
|
631
|
-
const offerId = c.req.param('offerId');
|
|
632
|
-
const peerId = getAuthenticatedPeerId(c);
|
|
633
|
-
|
|
634
|
-
const offer = await storage.getOfferById(offerId);
|
|
635
|
-
|
|
636
|
-
if (!offer) {
|
|
637
|
-
return c.json({ error: 'Offer not found' }, 404);
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
// Verify ownership
|
|
641
|
-
if (offer.peerId !== peerId) {
|
|
642
|
-
return c.json({ error: 'Not authorized to view this answer' }, 403);
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
// Check if answered
|
|
646
|
-
if (!offer.answererPeerId || !offer.answerSdp) {
|
|
647
|
-
return c.json({ error: 'Offer not yet answered' }, 404);
|
|
648
|
-
}
|
|
649
|
-
|
|
78
|
+
console.error('RPC error:', err);
|
|
650
79
|
return c.json({
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
answeredAt: offer.answeredAt
|
|
655
|
-
}, 200);
|
|
656
|
-
} catch (err) {
|
|
657
|
-
console.error('Error getting answer:', err);
|
|
658
|
-
return c.json({ error: 'Internal server error' }, 500);
|
|
80
|
+
success: false,
|
|
81
|
+
error: 'Invalid request format',
|
|
82
|
+
}, 400);
|
|
659
83
|
}
|
|
660
84
|
});
|
|
661
85
|
|
|
662
|
-
//
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
*/
|
|
668
|
-
app.post('/offers/:offerId/ice-candidates', authMiddleware, async (c) => {
|
|
669
|
-
try {
|
|
670
|
-
const offerId = c.req.param('offerId');
|
|
671
|
-
const body = await c.req.json();
|
|
672
|
-
const { candidates } = body;
|
|
673
|
-
|
|
674
|
-
if (!Array.isArray(candidates) || candidates.length === 0) {
|
|
675
|
-
return c.json({ error: 'Missing or invalid required parameter: candidates' }, 400);
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
const peerId = getAuthenticatedPeerId(c);
|
|
679
|
-
|
|
680
|
-
// Get offer to determine role
|
|
681
|
-
const offer = await storage.getOfferById(offerId);
|
|
682
|
-
if (!offer) {
|
|
683
|
-
return c.json({ error: 'Offer not found' }, 404);
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
// Determine role
|
|
687
|
-
const role = offer.peerId === peerId ? 'offerer' : 'answerer';
|
|
688
|
-
|
|
689
|
-
const count = await storage.addIceCandidates(offerId, peerId, role, candidates);
|
|
690
|
-
|
|
691
|
-
return c.json({ count }, 200);
|
|
692
|
-
} catch (err) {
|
|
693
|
-
console.error('Error adding ICE candidates:', err);
|
|
694
|
-
return c.json({ error: 'Internal server error' }, 500);
|
|
695
|
-
}
|
|
696
|
-
});
|
|
697
|
-
|
|
698
|
-
/**
|
|
699
|
-
* GET /offers/:offerId/ice-candidates
|
|
700
|
-
* Get ICE candidates for an offer
|
|
701
|
-
*/
|
|
702
|
-
app.get('/offers/:offerId/ice-candidates', authMiddleware, async (c) => {
|
|
703
|
-
try {
|
|
704
|
-
const offerId = c.req.param('offerId');
|
|
705
|
-
const since = c.req.query('since');
|
|
706
|
-
const peerId = getAuthenticatedPeerId(c);
|
|
707
|
-
|
|
708
|
-
// Get offer to determine role
|
|
709
|
-
const offer = await storage.getOfferById(offerId);
|
|
710
|
-
if (!offer) {
|
|
711
|
-
return c.json({ error: 'Offer not found' }, 404);
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
// Get candidates for opposite role
|
|
715
|
-
const targetRole = offer.peerId === peerId ? 'answerer' : 'offerer';
|
|
716
|
-
const sinceTimestamp = since ? parseInt(since, 10) : undefined;
|
|
717
|
-
|
|
718
|
-
const candidates = await storage.getIceCandidates(offerId, targetRole, sinceTimestamp);
|
|
719
|
-
|
|
720
|
-
return c.json({
|
|
721
|
-
candidates: candidates.map(c => ({
|
|
722
|
-
candidate: c.candidate,
|
|
723
|
-
createdAt: c.createdAt
|
|
724
|
-
}))
|
|
725
|
-
}, 200);
|
|
726
|
-
} catch (err) {
|
|
727
|
-
console.error('Error getting ICE candidates:', err);
|
|
728
|
-
return c.json({ error: 'Internal server error' }, 500);
|
|
729
|
-
}
|
|
86
|
+
// 404 for all other routes
|
|
87
|
+
app.all('*', (c) => {
|
|
88
|
+
return c.json({
|
|
89
|
+
error: 'Not found. Use POST /rpc for all API calls.',
|
|
90
|
+
}, 404);
|
|
730
91
|
});
|
|
731
92
|
|
|
732
93
|
return app;
|