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