@xtr-dev/rondevu-server 0.0.1 → 0.1.2
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/API.md +39 -9
- package/CLAUDE.md +47 -0
- package/README.md +144 -187
- package/build.js +12 -0
- package/dist/index.js +799 -266
- package/dist/index.js.map +4 -4
- package/migrations/0001_add_peer_id.sql +21 -0
- package/migrations/0002_remove_topics.sql +22 -0
- package/migrations/0003_remove_origin.sql +29 -0
- package/migrations/0004_add_secret.sql +4 -0
- package/migrations/schema.sql +18 -0
- package/package.json +4 -3
- package/src/app.ts +421 -127
- package/src/bloom.ts +66 -0
- package/src/config.ts +27 -2
- package/src/crypto.ts +149 -0
- package/src/index.ts +28 -12
- package/src/middleware/auth.ts +51 -0
- package/src/storage/d1.ts +394 -0
- package/src/storage/hash-id.ts +37 -0
- package/src/storage/sqlite.ts +323 -178
- package/src/storage/types.ts +128 -54
- package/src/worker.ts +51 -16
- package/wrangler.toml +45 -0
- package/DEPLOYMENT.md +0 -346
- package/src/storage/kv.ts +0 -241
package/src/app.ts
CHANGED
|
@@ -1,23 +1,37 @@
|
|
|
1
1
|
import { Hono } from 'hono';
|
|
2
2
|
import { cors } from 'hono/cors';
|
|
3
3
|
import { Storage } from './storage/types.ts';
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
}
|
|
4
|
+
import { Config } from './config.ts';
|
|
5
|
+
import { createAuthMiddleware, getAuthenticatedPeerId } from './middleware/auth.ts';
|
|
6
|
+
import { generatePeerId, encryptPeerId } from './crypto.ts';
|
|
7
|
+
import { parseBloomFilter } from './bloom.ts';
|
|
8
|
+
import type { Context } from 'hono';
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* Creates the Hono application with WebRTC signaling endpoints
|
|
11
|
+
* Creates the Hono application with topic-based WebRTC signaling endpoints
|
|
12
12
|
*/
|
|
13
|
-
export function createApp(storage: Storage, config:
|
|
13
|
+
export function createApp(storage: Storage, config: Config) {
|
|
14
14
|
const app = new Hono();
|
|
15
15
|
|
|
16
|
-
//
|
|
16
|
+
// Create auth middleware
|
|
17
|
+
const authMiddleware = createAuthMiddleware(config.authSecret);
|
|
18
|
+
|
|
19
|
+
// Enable CORS with dynamic origin handling
|
|
17
20
|
app.use('/*', cors({
|
|
18
|
-
origin:
|
|
19
|
-
|
|
20
|
-
|
|
21
|
+
origin: (origin) => {
|
|
22
|
+
// If no origin restrictions (wildcard), allow any origin
|
|
23
|
+
if (config.corsOrigins.length === 1 && config.corsOrigins[0] === '*') {
|
|
24
|
+
return origin;
|
|
25
|
+
}
|
|
26
|
+
// Otherwise check if origin is in allowed list
|
|
27
|
+
if (config.corsOrigins.includes(origin)) {
|
|
28
|
+
return origin;
|
|
29
|
+
}
|
|
30
|
+
// Default to first allowed origin
|
|
31
|
+
return config.corsOrigins[0];
|
|
32
|
+
},
|
|
33
|
+
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
|
34
|
+
allowHeaders: ['Content-Type', 'Origin', 'Authorization'],
|
|
21
35
|
exposeHeaders: ['Content-Type'],
|
|
22
36
|
maxAge: 600,
|
|
23
37
|
credentials: true,
|
|
@@ -25,203 +39,483 @@ export function createApp(storage: Storage, config: AppConfig) {
|
|
|
25
39
|
|
|
26
40
|
/**
|
|
27
41
|
* GET /
|
|
28
|
-
*
|
|
29
|
-
* Query params: page (default: 1), limit (default: 100, max: 1000)
|
|
42
|
+
* Returns server version information
|
|
30
43
|
*/
|
|
31
|
-
app.get('/',
|
|
44
|
+
app.get('/', (c) => {
|
|
45
|
+
return c.json({
|
|
46
|
+
version: config.version,
|
|
47
|
+
name: 'Rondevu',
|
|
48
|
+
description: 'Topic-based peer discovery and signaling server'
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* GET /health
|
|
54
|
+
* Health check endpoint with version
|
|
55
|
+
*/
|
|
56
|
+
app.get('/health', (c) => {
|
|
57
|
+
return c.json({
|
|
58
|
+
status: 'ok',
|
|
59
|
+
timestamp: Date.now(),
|
|
60
|
+
version: config.version
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* POST /register
|
|
66
|
+
* Register a new peer and receive credentials
|
|
67
|
+
*/
|
|
68
|
+
app.post('/register', async (c) => {
|
|
32
69
|
try {
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
const limit = parseInt(c.req.query('limit') || '100', 10);
|
|
70
|
+
// Generate new peer ID
|
|
71
|
+
const peerId = generatePeerId();
|
|
36
72
|
|
|
37
|
-
|
|
73
|
+
// Encrypt peer ID with server secret (async operation)
|
|
74
|
+
const secret = await encryptPeerId(peerId, config.authSecret);
|
|
38
75
|
|
|
39
|
-
return c.json(
|
|
76
|
+
return c.json({
|
|
77
|
+
peerId,
|
|
78
|
+
secret
|
|
79
|
+
}, 200);
|
|
40
80
|
} catch (err) {
|
|
41
|
-
console.error('Error
|
|
81
|
+
console.error('Error registering peer:', err);
|
|
42
82
|
return c.json({ error: 'Internal server error' }, 500);
|
|
43
83
|
}
|
|
44
84
|
});
|
|
45
85
|
|
|
46
86
|
/**
|
|
47
|
-
*
|
|
48
|
-
*
|
|
87
|
+
* POST /offers
|
|
88
|
+
* Creates one or more offers with topics
|
|
89
|
+
* Requires authentication
|
|
49
90
|
*/
|
|
50
|
-
app.
|
|
91
|
+
app.post('/offers', authMiddleware, async (c) => {
|
|
51
92
|
try {
|
|
52
|
-
const
|
|
53
|
-
const
|
|
93
|
+
const body = await c.req.json();
|
|
94
|
+
const { offers } = body;
|
|
54
95
|
|
|
55
|
-
if (!
|
|
56
|
-
return c.json({ error: 'Missing required parameter:
|
|
96
|
+
if (!Array.isArray(offers) || offers.length === 0) {
|
|
97
|
+
return c.json({ error: 'Missing or invalid required parameter: offers (must be non-empty array)' }, 400);
|
|
57
98
|
}
|
|
58
99
|
|
|
59
|
-
if (
|
|
60
|
-
return c.json({ error:
|
|
100
|
+
if (offers.length > config.maxOffersPerRequest) {
|
|
101
|
+
return c.json({ error: `Too many offers. Maximum ${config.maxOffersPerRequest} per request` }, 400);
|
|
61
102
|
}
|
|
62
103
|
|
|
63
|
-
const
|
|
104
|
+
const peerId = getAuthenticatedPeerId(c);
|
|
105
|
+
|
|
106
|
+
// Validate and prepare offers
|
|
107
|
+
const offerRequests = [];
|
|
108
|
+
for (const offer of offers) {
|
|
109
|
+
// Validate SDP
|
|
110
|
+
if (!offer.sdp || typeof offer.sdp !== 'string') {
|
|
111
|
+
return c.json({ error: 'Each offer must have an sdp field' }, 400);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (offer.sdp.length > 65536) {
|
|
115
|
+
return c.json({ error: 'SDP must be 64KB or less' }, 400);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Validate secret if provided
|
|
119
|
+
if (offer.secret !== undefined) {
|
|
120
|
+
if (typeof offer.secret !== 'string') {
|
|
121
|
+
return c.json({ error: 'Secret must be a string' }, 400);
|
|
122
|
+
}
|
|
123
|
+
if (offer.secret.length > 128) {
|
|
124
|
+
return c.json({ error: 'Secret must be 128 characters or less' }, 400);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Validate topics
|
|
129
|
+
if (!Array.isArray(offer.topics) || offer.topics.length === 0) {
|
|
130
|
+
return c.json({ error: 'Each offer must have a non-empty topics array' }, 400);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (offer.topics.length > config.maxTopicsPerOffer) {
|
|
134
|
+
return c.json({ error: `Too many topics. Maximum ${config.maxTopicsPerOffer} per offer` }, 400);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
for (const topic of offer.topics) {
|
|
138
|
+
if (typeof topic !== 'string' || topic.length === 0 || topic.length > 256) {
|
|
139
|
+
return c.json({ error: 'Each topic must be a string between 1 and 256 characters' }, 400);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Validate and clamp TTL
|
|
144
|
+
let ttl = offer.ttl || config.offerDefaultTtl;
|
|
145
|
+
if (ttl < config.offerMinTtl) {
|
|
146
|
+
ttl = config.offerMinTtl;
|
|
147
|
+
}
|
|
148
|
+
if (ttl > config.offerMaxTtl) {
|
|
149
|
+
ttl = config.offerMaxTtl;
|
|
150
|
+
}
|
|
64
151
|
|
|
152
|
+
offerRequests.push({
|
|
153
|
+
id: offer.id,
|
|
154
|
+
peerId,
|
|
155
|
+
sdp: offer.sdp,
|
|
156
|
+
topics: offer.topics,
|
|
157
|
+
expiresAt: Date.now() + ttl,
|
|
158
|
+
secret: offer.secret,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Create offers
|
|
163
|
+
const createdOffers = await storage.createOffers(offerRequests);
|
|
164
|
+
|
|
165
|
+
// Return simplified response
|
|
65
166
|
return c.json({
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
})),
|
|
74
|
-
});
|
|
167
|
+
offers: createdOffers.map(o => ({
|
|
168
|
+
id: o.id,
|
|
169
|
+
peerId: o.peerId,
|
|
170
|
+
topics: o.topics,
|
|
171
|
+
expiresAt: o.expiresAt
|
|
172
|
+
}))
|
|
173
|
+
}, 200);
|
|
75
174
|
} catch (err) {
|
|
76
|
-
console.error('Error
|
|
175
|
+
console.error('Error creating offers:', err);
|
|
77
176
|
return c.json({ error: 'Internal server error' }, 500);
|
|
78
177
|
}
|
|
79
178
|
});
|
|
80
179
|
|
|
81
180
|
/**
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
181
|
+
* GET /offers/by-topic/:topic
|
|
182
|
+
* Find offers by topic with optional bloom filter exclusion
|
|
183
|
+
* Public endpoint (no auth required)
|
|
85
184
|
*/
|
|
86
|
-
app.
|
|
185
|
+
app.get('/offers/by-topic/:topic', async (c) => {
|
|
87
186
|
try {
|
|
88
|
-
const origin = c.req.header('Origin') || c.req.header('origin') || 'unknown';
|
|
89
187
|
const topic = c.req.param('topic');
|
|
90
|
-
const
|
|
91
|
-
const
|
|
188
|
+
const bloomParam = c.req.query('bloom');
|
|
189
|
+
const limitParam = c.req.query('limit');
|
|
92
190
|
|
|
93
|
-
|
|
94
|
-
return c.json({ error: 'Missing or invalid required parameter: topic' }, 400);
|
|
95
|
-
}
|
|
191
|
+
const limit = limitParam ? Math.min(parseInt(limitParam, 10), 200) : 50;
|
|
96
192
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
193
|
+
// Parse bloom filter if provided
|
|
194
|
+
let excludePeerIds: string[] = [];
|
|
195
|
+
if (bloomParam) {
|
|
196
|
+
const bloom = parseBloomFilter(bloomParam);
|
|
197
|
+
if (!bloom) {
|
|
198
|
+
return c.json({ error: 'Invalid bloom filter format' }, 400);
|
|
199
|
+
}
|
|
100
200
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}
|
|
201
|
+
// Get all offers for topic first
|
|
202
|
+
const allOffers = await storage.getOffersByTopic(topic);
|
|
104
203
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
204
|
+
// Test each peer ID against bloom filter
|
|
205
|
+
const excludeSet = new Set<string>();
|
|
206
|
+
for (const offer of allOffers) {
|
|
207
|
+
if (bloom.test(offer.peerId)) {
|
|
208
|
+
excludeSet.add(offer.peerId);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
108
211
|
|
|
109
|
-
|
|
110
|
-
return c.json({ error: 'Missing or invalid required parameter: offer' }, 400);
|
|
212
|
+
excludePeerIds = Array.from(excludeSet);
|
|
111
213
|
}
|
|
112
214
|
|
|
113
|
-
|
|
114
|
-
|
|
215
|
+
// Get filtered offers
|
|
216
|
+
let offers = await storage.getOffersByTopic(topic, excludePeerIds.length > 0 ? excludePeerIds : undefined);
|
|
115
217
|
|
|
116
|
-
|
|
218
|
+
// Apply limit
|
|
219
|
+
const total = offers.length;
|
|
220
|
+
offers = offers.slice(0, limit);
|
|
221
|
+
|
|
222
|
+
return c.json({
|
|
223
|
+
topic,
|
|
224
|
+
offers: offers.map(o => ({
|
|
225
|
+
id: o.id,
|
|
226
|
+
peerId: o.peerId,
|
|
227
|
+
sdp: o.sdp,
|
|
228
|
+
topics: o.topics,
|
|
229
|
+
expiresAt: o.expiresAt,
|
|
230
|
+
lastSeen: o.lastSeen,
|
|
231
|
+
hasSecret: !!o.secret // Indicate if secret is required without exposing it
|
|
232
|
+
})),
|
|
233
|
+
total: bloomParam ? total + excludePeerIds.length : total,
|
|
234
|
+
returned: offers.length
|
|
235
|
+
}, 200);
|
|
117
236
|
} catch (err) {
|
|
118
|
-
console.error('Error
|
|
237
|
+
console.error('Error fetching offers by topic:', err);
|
|
119
238
|
return c.json({ error: 'Internal server error' }, 500);
|
|
120
239
|
}
|
|
121
240
|
});
|
|
122
241
|
|
|
123
242
|
/**
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
243
|
+
* GET /topics
|
|
244
|
+
* List all topics with active peer counts (paginated)
|
|
245
|
+
* Public endpoint (no auth required)
|
|
246
|
+
* Query params:
|
|
247
|
+
* - limit: Max topics to return (default 50, max 200)
|
|
248
|
+
* - offset: Number of topics to skip (default 0)
|
|
249
|
+
* - startsWith: Filter topics starting with this prefix (optional)
|
|
127
250
|
*/
|
|
128
|
-
app.
|
|
251
|
+
app.get('/topics', async (c) => {
|
|
129
252
|
try {
|
|
130
|
-
const
|
|
131
|
-
const
|
|
132
|
-
const
|
|
253
|
+
const limitParam = c.req.query('limit');
|
|
254
|
+
const offsetParam = c.req.query('offset');
|
|
255
|
+
const startsWithParam = c.req.query('startsWith');
|
|
133
256
|
|
|
134
|
-
|
|
135
|
-
|
|
257
|
+
const limit = limitParam ? Math.min(parseInt(limitParam, 10), 200) : 50;
|
|
258
|
+
const offset = offsetParam ? parseInt(offsetParam, 10) : 0;
|
|
259
|
+
const startsWith = startsWithParam || undefined;
|
|
260
|
+
|
|
261
|
+
const result = await storage.getTopics(limit, offset, startsWith);
|
|
262
|
+
|
|
263
|
+
return c.json({
|
|
264
|
+
topics: result.topics,
|
|
265
|
+
total: result.total,
|
|
266
|
+
limit,
|
|
267
|
+
offset,
|
|
268
|
+
...(startsWith && { startsWith })
|
|
269
|
+
}, 200);
|
|
270
|
+
} catch (err) {
|
|
271
|
+
console.error('Error fetching topics:', err);
|
|
272
|
+
return c.json({ error: 'Internal server error' }, 500);
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* GET /peers/:peerId/offers
|
|
278
|
+
* View all offers from a specific peer
|
|
279
|
+
* Public endpoint
|
|
280
|
+
*/
|
|
281
|
+
app.get('/peers/:peerId/offers', async (c) => {
|
|
282
|
+
try {
|
|
283
|
+
const peerId = c.req.param('peerId');
|
|
284
|
+
const offers = await storage.getOffersByPeerId(peerId);
|
|
285
|
+
|
|
286
|
+
// Collect unique topics
|
|
287
|
+
const topicsSet = new Set<string>();
|
|
288
|
+
offers.forEach(o => o.topics.forEach(t => topicsSet.add(t)));
|
|
289
|
+
|
|
290
|
+
return c.json({
|
|
291
|
+
peerId,
|
|
292
|
+
offers: offers.map(o => ({
|
|
293
|
+
id: o.id,
|
|
294
|
+
sdp: o.sdp,
|
|
295
|
+
topics: o.topics,
|
|
296
|
+
expiresAt: o.expiresAt,
|
|
297
|
+
lastSeen: o.lastSeen,
|
|
298
|
+
hasSecret: !!o.secret // Indicate if secret is required without exposing it
|
|
299
|
+
})),
|
|
300
|
+
topics: Array.from(topicsSet)
|
|
301
|
+
}, 200);
|
|
302
|
+
} catch (err) {
|
|
303
|
+
console.error('Error fetching peer offers:', err);
|
|
304
|
+
return c.json({ error: 'Internal server error' }, 500);
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* GET /offers/mine
|
|
310
|
+
* List all offers owned by authenticated peer
|
|
311
|
+
* Requires authentication
|
|
312
|
+
*/
|
|
313
|
+
app.get('/offers/mine', authMiddleware, async (c) => {
|
|
314
|
+
try {
|
|
315
|
+
const peerId = getAuthenticatedPeerId(c);
|
|
316
|
+
const offers = await storage.getOffersByPeerId(peerId);
|
|
317
|
+
|
|
318
|
+
return c.json({
|
|
319
|
+
peerId,
|
|
320
|
+
offers: offers.map(o => ({
|
|
321
|
+
id: o.id,
|
|
322
|
+
sdp: o.sdp,
|
|
323
|
+
topics: o.topics,
|
|
324
|
+
createdAt: o.createdAt,
|
|
325
|
+
expiresAt: o.expiresAt,
|
|
326
|
+
lastSeen: o.lastSeen,
|
|
327
|
+
secret: o.secret, // Owner can see the secret
|
|
328
|
+
answererPeerId: o.answererPeerId,
|
|
329
|
+
answeredAt: o.answeredAt
|
|
330
|
+
}))
|
|
331
|
+
}, 200);
|
|
332
|
+
} catch (err) {
|
|
333
|
+
console.error('Error fetching own offers:', err);
|
|
334
|
+
return c.json({ error: 'Internal server error' }, 500);
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* DELETE /offers/:offerId
|
|
340
|
+
* Delete a specific offer
|
|
341
|
+
* Requires authentication and ownership
|
|
342
|
+
*/
|
|
343
|
+
app.delete('/offers/:offerId', authMiddleware, async (c) => {
|
|
344
|
+
try {
|
|
345
|
+
const offerId = c.req.param('offerId');
|
|
346
|
+
const peerId = getAuthenticatedPeerId(c);
|
|
347
|
+
|
|
348
|
+
const deleted = await storage.deleteOffer(offerId, peerId);
|
|
349
|
+
|
|
350
|
+
if (!deleted) {
|
|
351
|
+
return c.json({ error: 'Offer not found or not authorized' }, 404);
|
|
136
352
|
}
|
|
137
353
|
|
|
138
|
-
|
|
139
|
-
|
|
354
|
+
return c.json({ deleted: true }, 200);
|
|
355
|
+
} catch (err) {
|
|
356
|
+
console.error('Error deleting offer:', err);
|
|
357
|
+
return c.json({ error: 'Internal server error' }, 500);
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* POST /offers/:offerId/answer
|
|
363
|
+
* Answer a specific offer (locks it to answerer)
|
|
364
|
+
* Requires authentication
|
|
365
|
+
*/
|
|
366
|
+
app.post('/offers/:offerId/answer', authMiddleware, async (c) => {
|
|
367
|
+
try {
|
|
368
|
+
const offerId = c.req.param('offerId');
|
|
369
|
+
const peerId = getAuthenticatedPeerId(c);
|
|
370
|
+
const body = await c.req.json();
|
|
371
|
+
const { sdp, secret } = body;
|
|
372
|
+
|
|
373
|
+
if (!sdp || typeof sdp !== 'string') {
|
|
374
|
+
return c.json({ error: 'Missing or invalid required parameter: sdp' }, 400);
|
|
140
375
|
}
|
|
141
376
|
|
|
142
|
-
if (
|
|
143
|
-
return c.json({ error: '
|
|
377
|
+
if (sdp.length > 65536) {
|
|
378
|
+
return c.json({ error: 'SDP must be 64KB or less' }, 400);
|
|
144
379
|
}
|
|
145
380
|
|
|
146
|
-
|
|
147
|
-
|
|
381
|
+
// Validate secret if provided
|
|
382
|
+
if (secret !== undefined && typeof secret !== 'string') {
|
|
383
|
+
return c.json({ error: 'Secret must be a string' }, 400);
|
|
148
384
|
}
|
|
149
385
|
|
|
150
|
-
const
|
|
386
|
+
const result = await storage.answerOffer(offerId, peerId, sdp, secret);
|
|
151
387
|
|
|
152
|
-
if (!
|
|
153
|
-
return c.json({ error:
|
|
388
|
+
if (!result.success) {
|
|
389
|
+
return c.json({ error: result.error }, 400);
|
|
154
390
|
}
|
|
155
391
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
392
|
+
return c.json({
|
|
393
|
+
offerId,
|
|
394
|
+
answererId: peerId,
|
|
395
|
+
answeredAt: Date.now()
|
|
396
|
+
}, 200);
|
|
397
|
+
} catch (err) {
|
|
398
|
+
console.error('Error answering offer:', err);
|
|
399
|
+
return c.json({ error: 'Internal server error' }, 500);
|
|
400
|
+
}
|
|
401
|
+
});
|
|
159
402
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
403
|
+
/**
|
|
404
|
+
* GET /offers/answers
|
|
405
|
+
* Poll for answers to all of authenticated peer's offers
|
|
406
|
+
* Requires authentication (offerer)
|
|
407
|
+
*/
|
|
408
|
+
app.get('/offers/answers', authMiddleware, async (c) => {
|
|
409
|
+
try {
|
|
410
|
+
const peerId = getAuthenticatedPeerId(c);
|
|
411
|
+
const offers = await storage.getAnsweredOffers(peerId);
|
|
169
412
|
|
|
170
|
-
return c.json({
|
|
413
|
+
return c.json({
|
|
414
|
+
answers: offers.map(o => ({
|
|
415
|
+
offerId: o.id,
|
|
416
|
+
answererId: o.answererPeerId,
|
|
417
|
+
sdp: o.answerSdp,
|
|
418
|
+
answeredAt: o.answeredAt,
|
|
419
|
+
topics: o.topics
|
|
420
|
+
}))
|
|
421
|
+
}, 200);
|
|
171
422
|
} catch (err) {
|
|
172
|
-
console.error('Error
|
|
423
|
+
console.error('Error fetching answers:', err);
|
|
173
424
|
return c.json({ error: 'Internal server error' }, 500);
|
|
174
425
|
}
|
|
175
426
|
});
|
|
176
427
|
|
|
177
428
|
/**
|
|
178
|
-
* POST /
|
|
179
|
-
*
|
|
180
|
-
*
|
|
429
|
+
* POST /offers/:offerId/ice-candidates
|
|
430
|
+
* Post ICE candidates for an offer
|
|
431
|
+
* Requires authentication (must be offerer or answerer)
|
|
181
432
|
*/
|
|
182
|
-
app.post('/
|
|
433
|
+
app.post('/offers/:offerId/ice-candidates', authMiddleware, async (c) => {
|
|
183
434
|
try {
|
|
184
|
-
const
|
|
435
|
+
const offerId = c.req.param('offerId');
|
|
436
|
+
const peerId = getAuthenticatedPeerId(c);
|
|
185
437
|
const body = await c.req.json();
|
|
186
|
-
const {
|
|
187
|
-
|
|
188
|
-
if (!code || typeof code !== 'string') {
|
|
189
|
-
return c.json({ error: 'Missing or invalid required parameter: code' }, 400);
|
|
190
|
-
}
|
|
438
|
+
const { candidates } = body;
|
|
191
439
|
|
|
192
|
-
if (!
|
|
193
|
-
return c.json({ error: '
|
|
440
|
+
if (!Array.isArray(candidates) || candidates.length === 0) {
|
|
441
|
+
return c.json({ error: 'Missing or invalid required parameter: candidates (must be non-empty array)' }, 400);
|
|
194
442
|
}
|
|
195
443
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
if (!
|
|
199
|
-
return c.json({ error: '
|
|
444
|
+
// Verify offer exists and caller is offerer or answerer
|
|
445
|
+
const offer = await storage.getOfferById(offerId);
|
|
446
|
+
if (!offer) {
|
|
447
|
+
return c.json({ error: 'Offer not found or expired' }, 404);
|
|
200
448
|
}
|
|
201
449
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
450
|
+
let role: 'offerer' | 'answerer';
|
|
451
|
+
if (offer.peerId === peerId) {
|
|
452
|
+
role = 'offerer';
|
|
453
|
+
} else if (offer.answererPeerId === peerId) {
|
|
454
|
+
role = 'answerer';
|
|
207
455
|
} else {
|
|
208
|
-
return c.json({
|
|
209
|
-
offer: session.offer,
|
|
210
|
-
offerCandidates: session.offerCandidates,
|
|
211
|
-
});
|
|
456
|
+
return c.json({ error: 'Not authorized to post ICE candidates for this offer' }, 403);
|
|
212
457
|
}
|
|
458
|
+
|
|
459
|
+
const added = await storage.addIceCandidates(offerId, peerId, role, candidates);
|
|
460
|
+
|
|
461
|
+
return c.json({
|
|
462
|
+
offerId,
|
|
463
|
+
candidatesAdded: added
|
|
464
|
+
}, 200);
|
|
213
465
|
} catch (err) {
|
|
214
|
-
console.error('Error
|
|
466
|
+
console.error('Error adding ICE candidates:', err);
|
|
215
467
|
return c.json({ error: 'Internal server error' }, 500);
|
|
216
468
|
}
|
|
217
469
|
});
|
|
218
470
|
|
|
219
471
|
/**
|
|
220
|
-
* GET /
|
|
221
|
-
*
|
|
472
|
+
* GET /offers/:offerId/ice-candidates
|
|
473
|
+
* Poll for ICE candidates from the other peer
|
|
474
|
+
* Requires authentication (must be offerer or answerer)
|
|
222
475
|
*/
|
|
223
|
-
app.get('/
|
|
224
|
-
|
|
476
|
+
app.get('/offers/:offerId/ice-candidates', authMiddleware, async (c) => {
|
|
477
|
+
try {
|
|
478
|
+
const offerId = c.req.param('offerId');
|
|
479
|
+
const peerId = getAuthenticatedPeerId(c);
|
|
480
|
+
const sinceParam = c.req.query('since');
|
|
481
|
+
|
|
482
|
+
const since = sinceParam ? parseInt(sinceParam, 10) : undefined;
|
|
483
|
+
|
|
484
|
+
// Verify offer exists and caller is offerer or answerer
|
|
485
|
+
const offer = await storage.getOfferById(offerId);
|
|
486
|
+
if (!offer) {
|
|
487
|
+
return c.json({ error: 'Offer not found or expired' }, 404);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
let targetRole: 'offerer' | 'answerer';
|
|
491
|
+
if (offer.peerId === peerId) {
|
|
492
|
+
// Offerer wants answerer's candidates
|
|
493
|
+
targetRole = 'answerer';
|
|
494
|
+
console.log(`[ICE GET] Offerer ${peerId} requesting answerer ICE candidates for offer ${offerId}, since=${since}, answererPeerId=${offer.answererPeerId}`);
|
|
495
|
+
} else if (offer.answererPeerId === peerId) {
|
|
496
|
+
// Answerer wants offerer's candidates
|
|
497
|
+
targetRole = 'offerer';
|
|
498
|
+
console.log(`[ICE GET] Answerer ${peerId} requesting offerer ICE candidates for offer ${offerId}, since=${since}, offererPeerId=${offer.peerId}`);
|
|
499
|
+
} else {
|
|
500
|
+
return c.json({ error: 'Not authorized to view ICE candidates for this offer' }, 403);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const candidates = await storage.getIceCandidates(offerId, targetRole, since);
|
|
504
|
+
console.log(`[ICE GET] Found ${candidates.length} candidates for offer ${offerId}, targetRole=${targetRole}, since=${since}`);
|
|
505
|
+
|
|
506
|
+
return c.json({
|
|
507
|
+
offerId,
|
|
508
|
+
candidates: candidates.map(c => ({
|
|
509
|
+
candidate: c.candidate,
|
|
510
|
+
peerId: c.peerId,
|
|
511
|
+
role: c.role,
|
|
512
|
+
createdAt: c.createdAt
|
|
513
|
+
}))
|
|
514
|
+
}, 200);
|
|
515
|
+
} catch (err) {
|
|
516
|
+
console.error('Error fetching ICE candidates:', err);
|
|
517
|
+
return c.json({ error: 'Internal server error' }, 500);
|
|
518
|
+
}
|
|
225
519
|
});
|
|
226
520
|
|
|
227
521
|
return app;
|