@xtr-dev/rondevu-server 0.1.5 → 0.2.1
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/README.md +217 -69
- package/dist/index.js +1070 -368
- package/dist/index.js.map +4 -4
- package/package.json +3 -2
- package/src/app.ts +339 -271
- package/src/crypto.ts +170 -0
- package/src/storage/d1.ts +295 -119
- package/src/storage/sqlite.ts +309 -107
- package/src/storage/types.ts +159 -29
package/src/app.ts
CHANGED
|
@@ -3,12 +3,11 @@ import { cors } from 'hono/cors';
|
|
|
3
3
|
import { Storage } from './storage/types.ts';
|
|
4
4
|
import { Config } from './config.ts';
|
|
5
5
|
import { createAuthMiddleware, getAuthenticatedPeerId } from './middleware/auth.ts';
|
|
6
|
-
import { generatePeerId, encryptPeerId } from './crypto.ts';
|
|
7
|
-
import { parseBloomFilter } from './bloom.ts';
|
|
6
|
+
import { generatePeerId, encryptPeerId, validateUsernameClaim, validateServiceFqn } from './crypto.ts';
|
|
8
7
|
import type { Context } from 'hono';
|
|
9
8
|
|
|
10
9
|
/**
|
|
11
|
-
* Creates the Hono application with
|
|
10
|
+
* Creates the Hono application with username and service-based WebRTC signaling
|
|
12
11
|
*/
|
|
13
12
|
export function createApp(storage: Storage, config: Config) {
|
|
14
13
|
const app = new Hono();
|
|
@@ -16,18 +15,15 @@ export function createApp(storage: Storage, config: Config) {
|
|
|
16
15
|
// Create auth middleware
|
|
17
16
|
const authMiddleware = createAuthMiddleware(config.authSecret);
|
|
18
17
|
|
|
19
|
-
// Enable CORS
|
|
18
|
+
// Enable CORS
|
|
20
19
|
app.use('/*', cors({
|
|
21
20
|
origin: (origin) => {
|
|
22
|
-
// If no origin restrictions (wildcard), allow any origin
|
|
23
21
|
if (config.corsOrigins.length === 1 && config.corsOrigins[0] === '*') {
|
|
24
22
|
return origin;
|
|
25
23
|
}
|
|
26
|
-
// Otherwise check if origin is in allowed list
|
|
27
24
|
if (config.corsOrigins.includes(origin)) {
|
|
28
25
|
return origin;
|
|
29
26
|
}
|
|
30
|
-
// Default to first allowed origin
|
|
31
27
|
return config.corsOrigins[0];
|
|
32
28
|
},
|
|
33
29
|
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
|
@@ -37,21 +33,23 @@ export function createApp(storage: Storage, config: Config) {
|
|
|
37
33
|
credentials: true,
|
|
38
34
|
}));
|
|
39
35
|
|
|
36
|
+
// ===== General Endpoints =====
|
|
37
|
+
|
|
40
38
|
/**
|
|
41
39
|
* GET /
|
|
42
|
-
* Returns server
|
|
40
|
+
* Returns server information
|
|
43
41
|
*/
|
|
44
42
|
app.get('/', (c) => {
|
|
45
43
|
return c.json({
|
|
46
44
|
version: config.version,
|
|
47
45
|
name: 'Rondevu',
|
|
48
|
-
description: '
|
|
46
|
+
description: 'DNS-like WebRTC signaling with username claiming and service discovery'
|
|
49
47
|
});
|
|
50
48
|
});
|
|
51
49
|
|
|
52
50
|
/**
|
|
53
51
|
* GET /health
|
|
54
|
-
* Health check endpoint
|
|
52
|
+
* Health check endpoint
|
|
55
53
|
*/
|
|
56
54
|
app.get('/health', (c) => {
|
|
57
55
|
return c.json({
|
|
@@ -63,15 +61,11 @@ export function createApp(storage: Storage, config: Config) {
|
|
|
63
61
|
|
|
64
62
|
/**
|
|
65
63
|
* POST /register
|
|
66
|
-
* Register a new peer
|
|
67
|
-
* Generates a cryptographically random peer ID (128-bit)
|
|
64
|
+
* Register a new peer (still needed for peer ID generation)
|
|
68
65
|
*/
|
|
69
66
|
app.post('/register', async (c) => {
|
|
70
67
|
try {
|
|
71
|
-
// Always generate a random peer ID
|
|
72
68
|
const peerId = generatePeerId();
|
|
73
|
-
|
|
74
|
-
// Encrypt peer ID with server secret (async operation)
|
|
75
69
|
const secret = await encryptPeerId(peerId, config.authSecret);
|
|
76
70
|
|
|
77
71
|
return c.json({
|
|
@@ -84,245 +78,353 @@ export function createApp(storage: Storage, config: Config) {
|
|
|
84
78
|
}
|
|
85
79
|
});
|
|
86
80
|
|
|
81
|
+
// ===== Username Management =====
|
|
82
|
+
|
|
87
83
|
/**
|
|
88
|
-
* POST /
|
|
89
|
-
*
|
|
90
|
-
* Requires authentication
|
|
84
|
+
* POST /usernames/claim
|
|
85
|
+
* Claim a username with cryptographic proof
|
|
91
86
|
*/
|
|
92
|
-
app.post('/
|
|
87
|
+
app.post('/usernames/claim', async (c) => {
|
|
93
88
|
try {
|
|
94
89
|
const body = await c.req.json();
|
|
95
|
-
const {
|
|
90
|
+
const { username, publicKey, signature, message } = body;
|
|
96
91
|
|
|
97
|
-
if (!
|
|
98
|
-
return c.json({ error: 'Missing
|
|
92
|
+
if (!username || !publicKey || !signature || !message) {
|
|
93
|
+
return c.json({ error: 'Missing required parameters: username, publicKey, signature, message' }, 400);
|
|
99
94
|
}
|
|
100
95
|
|
|
101
|
-
|
|
102
|
-
|
|
96
|
+
// Validate claim
|
|
97
|
+
const validation = await validateUsernameClaim(username, publicKey, signature, message);
|
|
98
|
+
if (!validation.valid) {
|
|
99
|
+
return c.json({ error: validation.error }, 400);
|
|
103
100
|
}
|
|
104
101
|
|
|
105
|
-
|
|
102
|
+
// Attempt to claim username
|
|
103
|
+
try {
|
|
104
|
+
const claimed = await storage.claimUsername({
|
|
105
|
+
username,
|
|
106
|
+
publicKey,
|
|
107
|
+
signature,
|
|
108
|
+
message
|
|
109
|
+
});
|
|
106
110
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
111
|
+
return c.json({
|
|
112
|
+
username: claimed.username,
|
|
113
|
+
claimedAt: claimed.claimedAt,
|
|
114
|
+
expiresAt: claimed.expiresAt
|
|
115
|
+
}, 200);
|
|
116
|
+
} catch (err: any) {
|
|
117
|
+
if (err.message?.includes('already claimed')) {
|
|
118
|
+
return c.json({ error: 'Username already claimed by different public key' }, 409);
|
|
113
119
|
}
|
|
120
|
+
throw err;
|
|
121
|
+
}
|
|
122
|
+
} catch (err) {
|
|
123
|
+
console.error('Error claiming username:', err);
|
|
124
|
+
return c.json({ error: 'Internal server error' }, 500);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
114
127
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
128
|
+
/**
|
|
129
|
+
* GET /usernames/:username
|
|
130
|
+
* Check if username is available or get claim info
|
|
131
|
+
*/
|
|
132
|
+
app.get('/usernames/:username', async (c) => {
|
|
133
|
+
try {
|
|
134
|
+
const username = c.req.param('username');
|
|
118
135
|
|
|
119
|
-
|
|
120
|
-
if (offer.secret !== undefined) {
|
|
121
|
-
if (typeof offer.secret !== 'string') {
|
|
122
|
-
return c.json({ error: 'Secret must be a string' }, 400);
|
|
123
|
-
}
|
|
124
|
-
if (offer.secret.length > 128) {
|
|
125
|
-
return c.json({ error: 'Secret must be 128 characters or less' }, 400);
|
|
126
|
-
}
|
|
127
|
-
}
|
|
136
|
+
const claimed = await storage.getUsername(username);
|
|
128
137
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
return c.json({ error: 'Info must be 128 characters or less' }, 400);
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
+
if (!claimed) {
|
|
139
|
+
return c.json({
|
|
140
|
+
username,
|
|
141
|
+
available: true
|
|
142
|
+
}, 200);
|
|
143
|
+
}
|
|
138
144
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
145
|
+
return c.json({
|
|
146
|
+
username: claimed.username,
|
|
147
|
+
available: false,
|
|
148
|
+
claimedAt: claimed.claimedAt,
|
|
149
|
+
expiresAt: claimed.expiresAt,
|
|
150
|
+
publicKey: claimed.publicKey
|
|
151
|
+
}, 200);
|
|
152
|
+
} catch (err) {
|
|
153
|
+
console.error('Error checking username:', err);
|
|
154
|
+
return c.json({ error: 'Internal server error' }, 500);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
143
157
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
158
|
+
/**
|
|
159
|
+
* GET /usernames/:username/services
|
|
160
|
+
* List services for a username (privacy-preserving)
|
|
161
|
+
*/
|
|
162
|
+
app.get('/usernames/:username/services', async (c) => {
|
|
163
|
+
try {
|
|
164
|
+
const username = c.req.param('username');
|
|
147
165
|
|
|
148
|
-
|
|
149
|
-
if (typeof topic !== 'string' || topic.length === 0 || topic.length > 256) {
|
|
150
|
-
return c.json({ error: 'Each topic must be a string between 1 and 256 characters' }, 400);
|
|
151
|
-
}
|
|
152
|
-
}
|
|
166
|
+
const services = await storage.listServicesForUsername(username);
|
|
153
167
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
168
|
+
return c.json({
|
|
169
|
+
username,
|
|
170
|
+
services
|
|
171
|
+
}, 200);
|
|
172
|
+
} catch (err) {
|
|
173
|
+
console.error('Error listing services:', err);
|
|
174
|
+
return c.json({ error: 'Internal server error' }, 500);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
162
177
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
178
|
+
// ===== Service Management =====
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* POST /services
|
|
182
|
+
* Publish a service
|
|
183
|
+
*/
|
|
184
|
+
app.post('/services', authMiddleware, async (c) => {
|
|
185
|
+
try {
|
|
186
|
+
const body = await c.req.json();
|
|
187
|
+
const { username, serviceFqn, sdp, ttl, isPublic, metadata, signature, message } = body;
|
|
188
|
+
|
|
189
|
+
if (!username || !serviceFqn || !sdp) {
|
|
190
|
+
return c.json({ error: 'Missing required parameters: username, serviceFqn, sdp' }, 400);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Validate service FQN
|
|
194
|
+
const fqnValidation = validateServiceFqn(serviceFqn);
|
|
195
|
+
if (!fqnValidation.valid) {
|
|
196
|
+
return c.json({ error: fqnValidation.error }, 400);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Verify username ownership (signature required)
|
|
200
|
+
if (!signature || !message) {
|
|
201
|
+
return c.json({ error: 'Missing signature or message for username verification' }, 400);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const usernameRecord = await storage.getUsername(username);
|
|
205
|
+
if (!usernameRecord) {
|
|
206
|
+
return c.json({ error: 'Username not claimed' }, 404);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Verify signature matches username's public key
|
|
210
|
+
const signatureValidation = await validateUsernameClaim(username, usernameRecord.publicKey, signature, message);
|
|
211
|
+
if (!signatureValidation.valid) {
|
|
212
|
+
return c.json({ error: 'Invalid signature for username' }, 403);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Validate SDP
|
|
216
|
+
if (typeof sdp !== 'string' || sdp.length === 0) {
|
|
217
|
+
return c.json({ error: 'Invalid SDP' }, 400);
|
|
172
218
|
}
|
|
173
219
|
|
|
174
|
-
|
|
175
|
-
|
|
220
|
+
if (sdp.length > 64 * 1024) {
|
|
221
|
+
return c.json({ error: 'SDP too large (max 64KB)' }, 400);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Calculate expiry
|
|
225
|
+
const peerId = getAuthenticatedPeerId(c);
|
|
226
|
+
const offerTtl = Math.min(
|
|
227
|
+
Math.max(ttl || config.offerDefaultTtl, config.offerMinTtl),
|
|
228
|
+
config.offerMaxTtl
|
|
229
|
+
);
|
|
230
|
+
const expiresAt = Date.now() + offerTtl;
|
|
231
|
+
|
|
232
|
+
// Create offer first
|
|
233
|
+
const offers = await storage.createOffers([{
|
|
234
|
+
peerId,
|
|
235
|
+
sdp,
|
|
236
|
+
expiresAt
|
|
237
|
+
}]);
|
|
238
|
+
|
|
239
|
+
if (offers.length === 0) {
|
|
240
|
+
return c.json({ error: 'Failed to create offer' }, 500);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const offer = offers[0];
|
|
244
|
+
|
|
245
|
+
// Create service
|
|
246
|
+
const result = await storage.createService({
|
|
247
|
+
username,
|
|
248
|
+
serviceFqn,
|
|
249
|
+
offerId: offer.id,
|
|
250
|
+
expiresAt,
|
|
251
|
+
isPublic: isPublic || false,
|
|
252
|
+
metadata: metadata ? JSON.stringify(metadata) : undefined
|
|
253
|
+
});
|
|
176
254
|
|
|
177
|
-
// Return simplified response
|
|
178
255
|
return c.json({
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
}))
|
|
185
|
-
}, 200);
|
|
256
|
+
serviceId: result.service.id,
|
|
257
|
+
uuid: result.indexUuid,
|
|
258
|
+
offerId: offer.id,
|
|
259
|
+
expiresAt: result.service.expiresAt
|
|
260
|
+
}, 201);
|
|
186
261
|
} catch (err) {
|
|
187
|
-
console.error('Error creating
|
|
262
|
+
console.error('Error creating service:', err);
|
|
188
263
|
return c.json({ error: 'Internal server error' }, 500);
|
|
189
264
|
}
|
|
190
265
|
});
|
|
191
266
|
|
|
192
267
|
/**
|
|
193
|
-
* GET /
|
|
194
|
-
*
|
|
195
|
-
* Public endpoint (no auth required)
|
|
268
|
+
* GET /services/:uuid
|
|
269
|
+
* Get service details by index UUID
|
|
196
270
|
*/
|
|
197
|
-
app.get('/
|
|
271
|
+
app.get('/services/:uuid', async (c) => {
|
|
198
272
|
try {
|
|
199
|
-
const
|
|
200
|
-
const bloomParam = c.req.query('bloom');
|
|
201
|
-
const limitParam = c.req.query('limit');
|
|
202
|
-
|
|
203
|
-
const limit = limitParam ? Math.min(parseInt(limitParam, 10), 200) : 50;
|
|
204
|
-
|
|
205
|
-
// Parse bloom filter if provided
|
|
206
|
-
let excludePeerIds: string[] = [];
|
|
207
|
-
if (bloomParam) {
|
|
208
|
-
const bloom = parseBloomFilter(bloomParam);
|
|
209
|
-
if (!bloom) {
|
|
210
|
-
return c.json({ error: 'Invalid bloom filter format' }, 400);
|
|
211
|
-
}
|
|
273
|
+
const uuid = c.req.param('uuid');
|
|
212
274
|
|
|
213
|
-
|
|
214
|
-
const allOffers = await storage.getOffersByTopic(topic);
|
|
215
|
-
|
|
216
|
-
// Test each peer ID against bloom filter
|
|
217
|
-
const excludeSet = new Set<string>();
|
|
218
|
-
for (const offer of allOffers) {
|
|
219
|
-
if (bloom.test(offer.peerId)) {
|
|
220
|
-
excludeSet.add(offer.peerId);
|
|
221
|
-
}
|
|
222
|
-
}
|
|
275
|
+
const service = await storage.getServiceByUuid(uuid);
|
|
223
276
|
|
|
224
|
-
|
|
277
|
+
if (!service) {
|
|
278
|
+
return c.json({ error: 'Service not found' }, 404);
|
|
225
279
|
}
|
|
226
280
|
|
|
227
|
-
// Get
|
|
228
|
-
|
|
281
|
+
// Get associated offer
|
|
282
|
+
const offer = await storage.getOfferById(service.offerId);
|
|
229
283
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
284
|
+
if (!offer) {
|
|
285
|
+
return c.json({ error: 'Associated offer not found' }, 404);
|
|
286
|
+
}
|
|
233
287
|
|
|
234
288
|
return c.json({
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
info: o.info // Public info field
|
|
245
|
-
})),
|
|
246
|
-
total: bloomParam ? total + excludePeerIds.length : total,
|
|
247
|
-
returned: offers.length
|
|
289
|
+
serviceId: service.id,
|
|
290
|
+
username: service.username,
|
|
291
|
+
serviceFqn: service.serviceFqn,
|
|
292
|
+
offerId: service.offerId,
|
|
293
|
+
sdp: offer.sdp,
|
|
294
|
+
isPublic: service.isPublic,
|
|
295
|
+
metadata: service.metadata ? JSON.parse(service.metadata) : undefined,
|
|
296
|
+
createdAt: service.createdAt,
|
|
297
|
+
expiresAt: service.expiresAt
|
|
248
298
|
}, 200);
|
|
249
299
|
} catch (err) {
|
|
250
|
-
console.error('Error
|
|
300
|
+
console.error('Error getting service:', err);
|
|
251
301
|
return c.json({ error: 'Internal server error' }, 500);
|
|
252
302
|
}
|
|
253
303
|
});
|
|
254
304
|
|
|
255
305
|
/**
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
* Public endpoint (no auth required)
|
|
259
|
-
* Query params:
|
|
260
|
-
* - limit: Max topics to return (default 50, max 200)
|
|
261
|
-
* - offset: Number of topics to skip (default 0)
|
|
262
|
-
* - startsWith: Filter topics starting with this prefix (optional)
|
|
306
|
+
* DELETE /services/:serviceId
|
|
307
|
+
* Delete a service (requires ownership)
|
|
263
308
|
*/
|
|
264
|
-
app.
|
|
309
|
+
app.delete('/services/:serviceId', authMiddleware, async (c) => {
|
|
265
310
|
try {
|
|
266
|
-
const
|
|
267
|
-
const
|
|
268
|
-
const
|
|
311
|
+
const serviceId = c.req.param('serviceId');
|
|
312
|
+
const body = await c.req.json();
|
|
313
|
+
const { username } = body;
|
|
314
|
+
|
|
315
|
+
if (!username) {
|
|
316
|
+
return c.json({ error: 'Missing required parameter: username' }, 400);
|
|
317
|
+
}
|
|
269
318
|
|
|
270
|
-
const
|
|
271
|
-
const offset = offsetParam ? parseInt(offsetParam, 10) : 0;
|
|
272
|
-
const startsWith = startsWithParam || undefined;
|
|
319
|
+
const deleted = await storage.deleteService(serviceId, username);
|
|
273
320
|
|
|
274
|
-
|
|
321
|
+
if (!deleted) {
|
|
322
|
+
return c.json({ error: 'Service not found or not owned by this username' }, 404);
|
|
323
|
+
}
|
|
275
324
|
|
|
276
|
-
return c.json({
|
|
277
|
-
topics: result.topics,
|
|
278
|
-
total: result.total,
|
|
279
|
-
limit,
|
|
280
|
-
offset,
|
|
281
|
-
...(startsWith && { startsWith })
|
|
282
|
-
}, 200);
|
|
325
|
+
return c.json({ success: true }, 200);
|
|
283
326
|
} catch (err) {
|
|
284
|
-
console.error('Error
|
|
327
|
+
console.error('Error deleting service:', err);
|
|
285
328
|
return c.json({ error: 'Internal server error' }, 500);
|
|
286
329
|
}
|
|
287
330
|
});
|
|
288
331
|
|
|
289
332
|
/**
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
* Public endpoint
|
|
333
|
+
* POST /index/:username/query
|
|
334
|
+
* Query service by FQN (returns UUID)
|
|
293
335
|
*/
|
|
294
|
-
app.
|
|
336
|
+
app.post('/index/:username/query', async (c) => {
|
|
295
337
|
try {
|
|
296
|
-
const
|
|
297
|
-
const
|
|
338
|
+
const username = c.req.param('username');
|
|
339
|
+
const body = await c.req.json();
|
|
340
|
+
const { serviceFqn } = body;
|
|
341
|
+
|
|
342
|
+
if (!serviceFqn) {
|
|
343
|
+
return c.json({ error: 'Missing required parameter: serviceFqn' }, 400);
|
|
344
|
+
}
|
|
298
345
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
346
|
+
const uuid = await storage.queryService(username, serviceFqn);
|
|
347
|
+
|
|
348
|
+
if (!uuid) {
|
|
349
|
+
return c.json({ error: 'Service not found' }, 404);
|
|
350
|
+
}
|
|
302
351
|
|
|
303
352
|
return c.json({
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
id: o.id,
|
|
307
|
-
sdp: o.sdp,
|
|
308
|
-
topics: o.topics,
|
|
309
|
-
expiresAt: o.expiresAt,
|
|
310
|
-
lastSeen: o.lastSeen,
|
|
311
|
-
hasSecret: !!o.secret, // Indicate if secret is required without exposing it
|
|
312
|
-
info: o.info // Public info field
|
|
313
|
-
})),
|
|
314
|
-
topics: Array.from(topicsSet)
|
|
353
|
+
uuid,
|
|
354
|
+
allowed: true
|
|
315
355
|
}, 200);
|
|
316
356
|
} catch (err) {
|
|
317
|
-
console.error('Error
|
|
357
|
+
console.error('Error querying service:', err);
|
|
318
358
|
return c.json({ error: 'Internal server error' }, 500);
|
|
319
359
|
}
|
|
320
360
|
});
|
|
321
361
|
|
|
362
|
+
// ===== Offer Management (Core WebRTC) =====
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* POST /offers
|
|
366
|
+
* Create offers (direct, no service - for testing/advanced users)
|
|
367
|
+
*/
|
|
368
|
+
app.post('/offers', authMiddleware, async (c) => {
|
|
369
|
+
try {
|
|
370
|
+
const body = await c.req.json();
|
|
371
|
+
const { offers } = body;
|
|
372
|
+
|
|
373
|
+
if (!Array.isArray(offers) || offers.length === 0) {
|
|
374
|
+
return c.json({ error: 'Missing or invalid required parameter: offers (must be non-empty array)' }, 400);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (offers.length > config.maxOffersPerRequest) {
|
|
378
|
+
return c.json({ error: `Too many offers (max ${config.maxOffersPerRequest})` }, 400);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const peerId = getAuthenticatedPeerId(c);
|
|
382
|
+
|
|
383
|
+
// Validate and prepare offers
|
|
384
|
+
const validated = offers.map((offer: any) => {
|
|
385
|
+
const { sdp, ttl, secret } = offer;
|
|
386
|
+
|
|
387
|
+
if (typeof sdp !== 'string' || sdp.length === 0) {
|
|
388
|
+
throw new Error('Invalid SDP in offer');
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (sdp.length > 64 * 1024) {
|
|
392
|
+
throw new Error('SDP too large (max 64KB)');
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const offerTtl = Math.min(
|
|
396
|
+
Math.max(ttl || config.offerDefaultTtl, config.offerMinTtl),
|
|
397
|
+
config.offerMaxTtl
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
return {
|
|
401
|
+
peerId,
|
|
402
|
+
sdp,
|
|
403
|
+
expiresAt: Date.now() + offerTtl,
|
|
404
|
+
secret: secret ? String(secret).substring(0, 128) : undefined
|
|
405
|
+
};
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
const created = await storage.createOffers(validated);
|
|
409
|
+
|
|
410
|
+
return c.json({
|
|
411
|
+
offers: created.map(offer => ({
|
|
412
|
+
id: offer.id,
|
|
413
|
+
peerId: offer.peerId,
|
|
414
|
+
expiresAt: offer.expiresAt,
|
|
415
|
+
createdAt: offer.createdAt,
|
|
416
|
+
hasSecret: !!offer.secret
|
|
417
|
+
}))
|
|
418
|
+
}, 201);
|
|
419
|
+
} catch (err: any) {
|
|
420
|
+
console.error('Error creating offers:', err);
|
|
421
|
+
return c.json({ error: err.message || 'Internal server error' }, 500);
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
|
|
322
425
|
/**
|
|
323
426
|
* GET /offers/mine
|
|
324
|
-
*
|
|
325
|
-
* Requires authentication
|
|
427
|
+
* Get authenticated peer's offers
|
|
326
428
|
*/
|
|
327
429
|
app.get('/offers/mine', authMiddleware, async (c) => {
|
|
328
430
|
try {
|
|
@@ -330,30 +432,26 @@ export function createApp(storage: Storage, config: Config) {
|
|
|
330
432
|
const offers = await storage.getOffersByPeerId(peerId);
|
|
331
433
|
|
|
332
434
|
return c.json({
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
info: o.info, // Owner can see the info
|
|
343
|
-
answererPeerId: o.answererPeerId,
|
|
344
|
-
answeredAt: o.answeredAt
|
|
435
|
+
offers: offers.map(offer => ({
|
|
436
|
+
id: offer.id,
|
|
437
|
+
sdp: offer.sdp,
|
|
438
|
+
createdAt: offer.createdAt,
|
|
439
|
+
expiresAt: offer.expiresAt,
|
|
440
|
+
lastSeen: offer.lastSeen,
|
|
441
|
+
hasSecret: !!offer.secret,
|
|
442
|
+
answererPeerId: offer.answererPeerId,
|
|
443
|
+
answered: !!offer.answererPeerId
|
|
345
444
|
}))
|
|
346
445
|
}, 200);
|
|
347
446
|
} catch (err) {
|
|
348
|
-
console.error('Error
|
|
447
|
+
console.error('Error getting offers:', err);
|
|
349
448
|
return c.json({ error: 'Internal server error' }, 500);
|
|
350
449
|
}
|
|
351
450
|
});
|
|
352
451
|
|
|
353
452
|
/**
|
|
354
453
|
* DELETE /offers/:offerId
|
|
355
|
-
* Delete
|
|
356
|
-
* Requires authentication and ownership
|
|
454
|
+
* Delete an offer
|
|
357
455
|
*/
|
|
358
456
|
app.delete('/offers/:offerId', authMiddleware, async (c) => {
|
|
359
457
|
try {
|
|
@@ -363,10 +461,10 @@ export function createApp(storage: Storage, config: Config) {
|
|
|
363
461
|
const deleted = await storage.deleteOffer(offerId, peerId);
|
|
364
462
|
|
|
365
463
|
if (!deleted) {
|
|
366
|
-
return c.json({ error: 'Offer not found or not
|
|
464
|
+
return c.json({ error: 'Offer not found or not owned by this peer' }, 404);
|
|
367
465
|
}
|
|
368
466
|
|
|
369
|
-
return c.json({
|
|
467
|
+
return c.json({ success: true }, 200);
|
|
370
468
|
} catch (err) {
|
|
371
469
|
console.error('Error deleting offer:', err);
|
|
372
470
|
return c.json({ error: 'Internal server error' }, 500);
|
|
@@ -375,40 +473,35 @@ export function createApp(storage: Storage, config: Config) {
|
|
|
375
473
|
|
|
376
474
|
/**
|
|
377
475
|
* POST /offers/:offerId/answer
|
|
378
|
-
* Answer
|
|
379
|
-
* Requires authentication
|
|
476
|
+
* Answer an offer
|
|
380
477
|
*/
|
|
381
478
|
app.post('/offers/:offerId/answer', authMiddleware, async (c) => {
|
|
382
479
|
try {
|
|
383
480
|
const offerId = c.req.param('offerId');
|
|
384
|
-
const peerId = getAuthenticatedPeerId(c);
|
|
385
481
|
const body = await c.req.json();
|
|
386
482
|
const { sdp, secret } = body;
|
|
387
483
|
|
|
388
|
-
if (!sdp
|
|
389
|
-
return c.json({ error: 'Missing
|
|
484
|
+
if (!sdp) {
|
|
485
|
+
return c.json({ error: 'Missing required parameter: sdp' }, 400);
|
|
390
486
|
}
|
|
391
487
|
|
|
392
|
-
if (sdp.length
|
|
393
|
-
return c.json({ error: 'SDP
|
|
488
|
+
if (typeof sdp !== 'string' || sdp.length === 0) {
|
|
489
|
+
return c.json({ error: 'Invalid SDP' }, 400);
|
|
394
490
|
}
|
|
395
491
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
return c.json({ error: 'Secret must be a string' }, 400);
|
|
492
|
+
if (sdp.length > 64 * 1024) {
|
|
493
|
+
return c.json({ error: 'SDP too large (max 64KB)' }, 400);
|
|
399
494
|
}
|
|
400
495
|
|
|
401
|
-
const
|
|
496
|
+
const answererPeerId = getAuthenticatedPeerId(c);
|
|
497
|
+
|
|
498
|
+
const result = await storage.answerOffer(offerId, answererPeerId, sdp, secret);
|
|
402
499
|
|
|
403
500
|
if (!result.success) {
|
|
404
501
|
return c.json({ error: result.error }, 400);
|
|
405
502
|
}
|
|
406
503
|
|
|
407
|
-
return c.json({
|
|
408
|
-
offerId,
|
|
409
|
-
answererId: peerId,
|
|
410
|
-
answeredAt: Date.now()
|
|
411
|
-
}, 200);
|
|
504
|
+
return c.json({ success: true }, 200);
|
|
412
505
|
} catch (err) {
|
|
413
506
|
console.error('Error answering offer:', err);
|
|
414
507
|
return c.json({ error: 'Internal server error' }, 500);
|
|
@@ -417,8 +510,7 @@ export function createApp(storage: Storage, config: Config) {
|
|
|
417
510
|
|
|
418
511
|
/**
|
|
419
512
|
* GET /offers/answers
|
|
420
|
-
*
|
|
421
|
-
* Requires authentication (offerer)
|
|
513
|
+
* Get answers for authenticated peer's offers
|
|
422
514
|
*/
|
|
423
515
|
app.get('/offers/answers', authMiddleware, async (c) => {
|
|
424
516
|
try {
|
|
@@ -426,57 +518,49 @@ export function createApp(storage: Storage, config: Config) {
|
|
|
426
518
|
const offers = await storage.getAnsweredOffers(peerId);
|
|
427
519
|
|
|
428
520
|
return c.json({
|
|
429
|
-
answers: offers.map(
|
|
430
|
-
offerId:
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
answeredAt:
|
|
434
|
-
topics: o.topics
|
|
521
|
+
answers: offers.map(offer => ({
|
|
522
|
+
offerId: offer.id,
|
|
523
|
+
answererPeerId: offer.answererPeerId,
|
|
524
|
+
answerSdp: offer.answerSdp,
|
|
525
|
+
answeredAt: offer.answeredAt
|
|
435
526
|
}))
|
|
436
527
|
}, 200);
|
|
437
528
|
} catch (err) {
|
|
438
|
-
console.error('Error
|
|
529
|
+
console.error('Error getting answers:', err);
|
|
439
530
|
return c.json({ error: 'Internal server error' }, 500);
|
|
440
531
|
}
|
|
441
532
|
});
|
|
442
533
|
|
|
534
|
+
// ===== ICE Candidate Exchange =====
|
|
535
|
+
|
|
443
536
|
/**
|
|
444
537
|
* POST /offers/:offerId/ice-candidates
|
|
445
|
-
*
|
|
446
|
-
* Requires authentication (must be offerer or answerer)
|
|
538
|
+
* Add ICE candidates for an offer
|
|
447
539
|
*/
|
|
448
540
|
app.post('/offers/:offerId/ice-candidates', authMiddleware, async (c) => {
|
|
449
541
|
try {
|
|
450
542
|
const offerId = c.req.param('offerId');
|
|
451
|
-
const peerId = getAuthenticatedPeerId(c);
|
|
452
543
|
const body = await c.req.json();
|
|
453
544
|
const { candidates } = body;
|
|
454
545
|
|
|
455
546
|
if (!Array.isArray(candidates) || candidates.length === 0) {
|
|
456
|
-
return c.json({ error: 'Missing or invalid required parameter: candidates
|
|
547
|
+
return c.json({ error: 'Missing or invalid required parameter: candidates' }, 400);
|
|
457
548
|
}
|
|
458
549
|
|
|
459
|
-
|
|
550
|
+
const peerId = getAuthenticatedPeerId(c);
|
|
551
|
+
|
|
552
|
+
// Get offer to determine role
|
|
460
553
|
const offer = await storage.getOfferById(offerId);
|
|
461
554
|
if (!offer) {
|
|
462
|
-
return c.json({ error: 'Offer not found
|
|
555
|
+
return c.json({ error: 'Offer not found' }, 404);
|
|
463
556
|
}
|
|
464
557
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
role = 'offerer';
|
|
468
|
-
} else if (offer.answererPeerId === peerId) {
|
|
469
|
-
role = 'answerer';
|
|
470
|
-
} else {
|
|
471
|
-
return c.json({ error: 'Not authorized to post ICE candidates for this offer' }, 403);
|
|
472
|
-
}
|
|
558
|
+
// Determine role
|
|
559
|
+
const role = offer.peerId === peerId ? 'offerer' : 'answerer';
|
|
473
560
|
|
|
474
|
-
const
|
|
561
|
+
const count = await storage.addIceCandidates(offerId, peerId, role, candidates);
|
|
475
562
|
|
|
476
|
-
return c.json({
|
|
477
|
-
offerId,
|
|
478
|
-
candidatesAdded: added
|
|
479
|
-
}, 200);
|
|
563
|
+
return c.json({ count }, 200);
|
|
480
564
|
} catch (err) {
|
|
481
565
|
console.error('Error adding ICE candidates:', err);
|
|
482
566
|
return c.json({ error: 'Internal server error' }, 500);
|
|
@@ -485,50 +569,34 @@ export function createApp(storage: Storage, config: Config) {
|
|
|
485
569
|
|
|
486
570
|
/**
|
|
487
571
|
* GET /offers/:offerId/ice-candidates
|
|
488
|
-
*
|
|
489
|
-
* Requires authentication (must be offerer or answerer)
|
|
572
|
+
* Get ICE candidates for an offer
|
|
490
573
|
*/
|
|
491
574
|
app.get('/offers/:offerId/ice-candidates', authMiddleware, async (c) => {
|
|
492
575
|
try {
|
|
493
576
|
const offerId = c.req.param('offerId');
|
|
577
|
+
const since = c.req.query('since');
|
|
494
578
|
const peerId = getAuthenticatedPeerId(c);
|
|
495
|
-
const sinceParam = c.req.query('since');
|
|
496
579
|
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
// Verify offer exists and caller is offerer or answerer
|
|
580
|
+
// Get offer to determine role
|
|
500
581
|
const offer = await storage.getOfferById(offerId);
|
|
501
582
|
if (!offer) {
|
|
502
|
-
return c.json({ error: 'Offer not found
|
|
583
|
+
return c.json({ error: 'Offer not found' }, 404);
|
|
503
584
|
}
|
|
504
585
|
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
targetRole = 'answerer';
|
|
509
|
-
console.log(`[ICE GET] Offerer ${peerId} requesting answerer ICE candidates for offer ${offerId}, since=${since}, answererPeerId=${offer.answererPeerId}`);
|
|
510
|
-
} else if (offer.answererPeerId === peerId) {
|
|
511
|
-
// Answerer wants offerer's candidates
|
|
512
|
-
targetRole = 'offerer';
|
|
513
|
-
console.log(`[ICE GET] Answerer ${peerId} requesting offerer ICE candidates for offer ${offerId}, since=${since}, offererPeerId=${offer.peerId}`);
|
|
514
|
-
} else {
|
|
515
|
-
return c.json({ error: 'Not authorized to view ICE candidates for this offer' }, 403);
|
|
516
|
-
}
|
|
586
|
+
// Get candidates for opposite role
|
|
587
|
+
const targetRole = offer.peerId === peerId ? 'answerer' : 'offerer';
|
|
588
|
+
const sinceTimestamp = since ? parseInt(since, 10) : undefined;
|
|
517
589
|
|
|
518
|
-
const candidates = await storage.getIceCandidates(offerId, targetRole,
|
|
519
|
-
console.log(`[ICE GET] Found ${candidates.length} candidates for offer ${offerId}, targetRole=${targetRole}, since=${since}`);
|
|
590
|
+
const candidates = await storage.getIceCandidates(offerId, targetRole, sinceTimestamp);
|
|
520
591
|
|
|
521
592
|
return c.json({
|
|
522
|
-
offerId,
|
|
523
593
|
candidates: candidates.map(c => ({
|
|
524
594
|
candidate: c.candidate,
|
|
525
|
-
peerId: c.peerId,
|
|
526
|
-
role: c.role,
|
|
527
595
|
createdAt: c.createdAt
|
|
528
596
|
}))
|
|
529
597
|
}, 200);
|
|
530
598
|
} catch (err) {
|
|
531
|
-
console.error('Error
|
|
599
|
+
console.error('Error getting ICE candidates:', err);
|
|
532
600
|
return c.json({ error: 'Internal server error' }, 500);
|
|
533
601
|
}
|
|
534
602
|
});
|