@xtr-dev/rondevu-server 0.5.1 → 0.5.7
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/.github/workflows/claude-code-review.yml +57 -0
- package/.github/workflows/claude.yml +50 -0
- package/.idea/modules.xml +8 -0
- package/.idea/rondevu-server.iml +8 -0
- package/.idea/workspace.xml +17 -0
- package/README.md +80 -199
- package/build.js +4 -1
- package/dist/index.js +2891 -1446
- package/dist/index.js.map +4 -4
- package/migrations/fresh_schema.sql +36 -41
- package/package.json +10 -4
- package/src/app.ts +38 -18
- package/src/config.ts +183 -9
- package/src/crypto.ts +361 -263
- package/src/index.ts +20 -25
- package/src/rpc.ts +714 -403
- package/src/storage/d1.ts +338 -263
- package/src/storage/factory.ts +69 -0
- package/src/storage/hash-id.ts +13 -9
- package/src/storage/memory.ts +579 -0
- package/src/storage/mysql.ts +616 -0
- package/src/storage/postgres.ts +623 -0
- package/src/storage/schemas/mysql.sql +59 -0
- package/src/storage/schemas/postgres.sql +64 -0
- package/src/storage/sqlite.ts +325 -269
- package/src/storage/types.ts +137 -109
- package/src/worker.ts +15 -34
- package/tests/integration/api.test.ts +395 -0
- package/tests/integration/setup.ts +170 -0
- package/wrangler.toml +25 -26
- package/ADVANCED.md +0 -502
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for api.ronde.vu
|
|
3
|
+
*
|
|
4
|
+
* Run with: npm test
|
|
5
|
+
*
|
|
6
|
+
* Note: Uses shared credentials to avoid rate limits (10 per hour)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, before } from 'node:test'
|
|
10
|
+
import assert from 'node:assert'
|
|
11
|
+
import { createTestContext, generateCredentials, rpc, sleep, API_URL, Credential } from './setup.js'
|
|
12
|
+
|
|
13
|
+
describe('Integration Tests - api.ronde.vu', () => {
|
|
14
|
+
console.log(`Testing against: ${API_URL}`)
|
|
15
|
+
|
|
16
|
+
// Shared credentials - created once, reused across tests
|
|
17
|
+
let userA: Awaited<ReturnType<typeof createTestContext>>
|
|
18
|
+
let userB: Awaited<ReturnType<typeof createTestContext>>
|
|
19
|
+
let userC: Awaited<ReturnType<typeof createTestContext>>
|
|
20
|
+
|
|
21
|
+
before(async () => {
|
|
22
|
+
// Create 3 shared user contexts for all tests
|
|
23
|
+
userA = await createTestContext()
|
|
24
|
+
userB = await createTestContext()
|
|
25
|
+
userC = await createTestContext()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe('1. Authentication', () => {
|
|
29
|
+
it('should generate valid credentials', async () => {
|
|
30
|
+
// Use already-generated credentials from userA
|
|
31
|
+
assert.ok(userA.credential.name, 'Should have a name')
|
|
32
|
+
assert.ok(userA.credential.secret, 'Should have a secret')
|
|
33
|
+
assert.strictEqual(typeof userA.credential.name, 'string')
|
|
34
|
+
assert.strictEqual(typeof userA.credential.secret, 'string')
|
|
35
|
+
assert.strictEqual(userA.credential.secret.length, 64, 'Secret should be 32 bytes (64 hex chars)')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('should reject requests without authentication for protected methods', async () => {
|
|
39
|
+
await assert.rejects(
|
|
40
|
+
async () => {
|
|
41
|
+
await rpc('publishOffer', {
|
|
42
|
+
tags: ['test'],
|
|
43
|
+
offers: [{ sdp: 'test-sdp' }],
|
|
44
|
+
ttl: 60000
|
|
45
|
+
})
|
|
46
|
+
},
|
|
47
|
+
/AUTH_REQUIRED|authentication|unauthorized/i,
|
|
48
|
+
'Should reject unauthenticated publish request'
|
|
49
|
+
)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('should reject invalid signatures', async () => {
|
|
53
|
+
// Create a credential with wrong secret (reuse userA's name)
|
|
54
|
+
const badCredential: Credential = {
|
|
55
|
+
name: userA.credential.name,
|
|
56
|
+
secret: '0'.repeat(64) // Invalid secret
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
await assert.rejects(
|
|
60
|
+
async () => {
|
|
61
|
+
await rpc('publishOffer', {
|
|
62
|
+
tags: ['test'],
|
|
63
|
+
offers: [{ sdp: 'test-sdp' }],
|
|
64
|
+
ttl: 60000
|
|
65
|
+
}, badCredential)
|
|
66
|
+
},
|
|
67
|
+
/INVALID_CREDENTIALS|signature|invalid/i,
|
|
68
|
+
'Should reject invalid signature'
|
|
69
|
+
)
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('2. Publish Offers', () => {
|
|
74
|
+
it('should publish offers with tags', async () => {
|
|
75
|
+
const result = await userA.api.publish({
|
|
76
|
+
tags: ['chat', 'video'],
|
|
77
|
+
offers: [
|
|
78
|
+
{ sdp: 'v=0\r\no=- 123 1 IN IP4 127.0.0.1\r\n' }
|
|
79
|
+
],
|
|
80
|
+
ttl: 60000
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
assert.ok(result.offers, 'Should return offers array')
|
|
84
|
+
assert.strictEqual(result.offers.length, 1, 'Should have one offer')
|
|
85
|
+
assert.ok(result.offers[0].offerId, 'Offer should have an ID')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('should publish multiple offers at once', async () => {
|
|
89
|
+
const result = await userA.api.publish({
|
|
90
|
+
tags: ['multi-test'],
|
|
91
|
+
offers: [
|
|
92
|
+
{ sdp: 'sdp-1' },
|
|
93
|
+
{ sdp: 'sdp-2' },
|
|
94
|
+
{ sdp: 'sdp-3' }
|
|
95
|
+
],
|
|
96
|
+
ttl: 60000
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
assert.strictEqual(result.offers.length, 3, 'Should have three offers')
|
|
100
|
+
|
|
101
|
+
const offerIds = result.offers.map((o: any) => o.offerId)
|
|
102
|
+
const uniqueIds = new Set(offerIds)
|
|
103
|
+
assert.strictEqual(uniqueIds.size, 3, 'All offer IDs should be unique')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('should reject empty tags array', async () => {
|
|
107
|
+
await assert.rejects(
|
|
108
|
+
async () => {
|
|
109
|
+
await userA.api.publish({
|
|
110
|
+
tags: [],
|
|
111
|
+
offers: [{ sdp: 'test' }],
|
|
112
|
+
ttl: 60000
|
|
113
|
+
})
|
|
114
|
+
},
|
|
115
|
+
/tag|required|empty/i,
|
|
116
|
+
'Should reject empty tags'
|
|
117
|
+
)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('should enforce tag format validation', async () => {
|
|
121
|
+
await assert.rejects(
|
|
122
|
+
async () => {
|
|
123
|
+
await userA.api.publish({
|
|
124
|
+
tags: ['invalid tag with spaces!'],
|
|
125
|
+
offers: [{ sdp: 'test' }],
|
|
126
|
+
ttl: 60000
|
|
127
|
+
})
|
|
128
|
+
},
|
|
129
|
+
/tag|invalid|format/i,
|
|
130
|
+
'Should reject invalid tag format'
|
|
131
|
+
)
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
describe('3. Discover by Tags', () => {
|
|
136
|
+
let publishedOfferId: string
|
|
137
|
+
|
|
138
|
+
before(async () => {
|
|
139
|
+
// userA publishes offers for discovery tests
|
|
140
|
+
const result = await userA.api.publish({
|
|
141
|
+
tags: ['discover-test', 'video-discover'],
|
|
142
|
+
offers: [{ sdp: 'discover-test-sdp' }],
|
|
143
|
+
ttl: 120000
|
|
144
|
+
})
|
|
145
|
+
publishedOfferId = result.offers[0].offerId
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('should discover offers matching ANY tag (OR logic)', async () => {
|
|
149
|
+
// Search for 'video-discover' tag only (unauthenticated, paginated mode)
|
|
150
|
+
const result = await rpc('discover', { tags: ['video-discover'], limit: 10 }) as any
|
|
151
|
+
|
|
152
|
+
assert.ok(result.offers, 'Should return offers')
|
|
153
|
+
const found = result.offers.find((o: any) => o.offerId === publishedOfferId)
|
|
154
|
+
assert.ok(found, 'Should find published offer by video tag')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('should discover with multiple tags (OR)', async () => {
|
|
158
|
+
// Search for either 'discover-test' or 'nonexistent' (paginated mode)
|
|
159
|
+
const result = await rpc('discover', {
|
|
160
|
+
tags: ['discover-test', 'nonexistent-tag'],
|
|
161
|
+
limit: 10
|
|
162
|
+
}) as any
|
|
163
|
+
|
|
164
|
+
assert.ok(result.offers, 'Should return offers')
|
|
165
|
+
const found = result.offers.find((o: any) => o.offerId === publishedOfferId)
|
|
166
|
+
assert.ok(found, 'Should find offer matching at least one tag')
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('should support pagination', async () => {
|
|
170
|
+
const result = await rpc('discover', {
|
|
171
|
+
tags: ['discover-test'],
|
|
172
|
+
limit: 5,
|
|
173
|
+
offset: 0
|
|
174
|
+
}) as any
|
|
175
|
+
|
|
176
|
+
assert.ok(result.offers !== undefined, 'Should return offers array')
|
|
177
|
+
assert.ok(typeof result.count === 'number', 'Should return count')
|
|
178
|
+
assert.ok(typeof result.limit === 'number', 'Should return limit')
|
|
179
|
+
assert.ok(typeof result.offset === 'number', 'Should return offset')
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('should exclude own offers from discovery', async () => {
|
|
183
|
+
// userA should not see their own offers (paginated mode)
|
|
184
|
+
const result = await userA.api.discover({
|
|
185
|
+
tags: ['discover-test'],
|
|
186
|
+
limit: 10
|
|
187
|
+
}) as any
|
|
188
|
+
|
|
189
|
+
const foundOwn = result.offers?.find((o: any) => o.offerId === publishedOfferId)
|
|
190
|
+
assert.ok(!foundOwn, 'Should not find own offers in discovery')
|
|
191
|
+
})
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
describe('4. Answer Offer', () => {
|
|
195
|
+
let offerId: string
|
|
196
|
+
|
|
197
|
+
before(async () => {
|
|
198
|
+
// userA publishes offer, userB will answer it
|
|
199
|
+
const result = await userA.api.publish({
|
|
200
|
+
tags: ['answer-test'],
|
|
201
|
+
offers: [{ sdp: 'offer-sdp-for-answer' }],
|
|
202
|
+
ttl: 120000
|
|
203
|
+
})
|
|
204
|
+
offerId = result.offers[0].offerId
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('should answer an available offer', async () => {
|
|
208
|
+
await userB.api.answerOffer(offerId, 'answer-sdp-content')
|
|
209
|
+
// If no error, answer was successful
|
|
210
|
+
assert.ok(true, 'Should successfully answer offer')
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('should reject answering already-answered offer', async () => {
|
|
214
|
+
// Try to answer the same offer again with userC
|
|
215
|
+
await assert.rejects(
|
|
216
|
+
async () => {
|
|
217
|
+
await userC.api.answerOffer(offerId, 'another-answer-sdp')
|
|
218
|
+
},
|
|
219
|
+
/OFFER_ALREADY_ANSWERED|already|answered|claimed/i,
|
|
220
|
+
'Should reject answering already-answered offer'
|
|
221
|
+
)
|
|
222
|
+
})
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
describe('5. ICE Candidates', () => {
|
|
226
|
+
let offerId: string
|
|
227
|
+
|
|
228
|
+
before(async () => {
|
|
229
|
+
// userB publishes offer, userC answers
|
|
230
|
+
const publishResult = await userB.api.publish({
|
|
231
|
+
tags: ['ice-test'],
|
|
232
|
+
offers: [{ sdp: 'ice-test-offer-sdp' }],
|
|
233
|
+
ttl: 120000
|
|
234
|
+
})
|
|
235
|
+
offerId = publishResult.offers[0].offerId
|
|
236
|
+
|
|
237
|
+
await userC.api.answerOffer(offerId, 'ice-test-answer-sdp')
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('should add ICE candidates as offerer', async () => {
|
|
241
|
+
const result = await userB.api.addOfferIceCandidates(offerId, [
|
|
242
|
+
{ candidate: 'candidate:1 1 UDP 2130706431 192.168.1.1 54321 typ host' }
|
|
243
|
+
])
|
|
244
|
+
|
|
245
|
+
assert.ok(result.count >= 0, 'Should return candidate count')
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('should add ICE candidates as answerer', async () => {
|
|
249
|
+
const result = await userC.api.addOfferIceCandidates(offerId, [
|
|
250
|
+
{ candidate: 'candidate:2 1 UDP 2130706431 192.168.1.2 54322 typ host' }
|
|
251
|
+
])
|
|
252
|
+
|
|
253
|
+
assert.ok(result.count >= 0, 'Should return candidate count')
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('should retrieve ICE candidates filtered by role', async () => {
|
|
257
|
+
await sleep(100) // Small delay to ensure candidates are stored
|
|
258
|
+
|
|
259
|
+
// Offerer should receive answerer's candidates
|
|
260
|
+
const offererResult = await userB.api.getOfferIceCandidates(offerId, 0)
|
|
261
|
+
|
|
262
|
+
assert.ok(Array.isArray(offererResult.candidates), 'Should return candidates array')
|
|
263
|
+
|
|
264
|
+
// Answerer should receive offerer's candidates
|
|
265
|
+
const answererResult = await userC.api.getOfferIceCandidates(offerId, 0)
|
|
266
|
+
|
|
267
|
+
assert.ok(Array.isArray(answererResult.candidates), 'Should return candidates array')
|
|
268
|
+
})
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
describe('6. Polling', () => {
|
|
272
|
+
let offerId: string
|
|
273
|
+
|
|
274
|
+
before(async () => {
|
|
275
|
+
// userC publishes offer
|
|
276
|
+
const result = await userC.api.publish({
|
|
277
|
+
tags: ['poll-test'],
|
|
278
|
+
offers: [{ sdp: 'poll-test-offer' }],
|
|
279
|
+
ttl: 120000
|
|
280
|
+
})
|
|
281
|
+
offerId = result.offers[0].offerId
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
it('should poll for new answers', async () => {
|
|
285
|
+
// First poll - should be empty (or have previous answers)
|
|
286
|
+
const before = await userC.api.poll(0)
|
|
287
|
+
const initialAnswerCount = before.answers.length
|
|
288
|
+
|
|
289
|
+
// userA answers the offer
|
|
290
|
+
await userA.api.answerOffer(offerId, 'poll-test-answer')
|
|
291
|
+
|
|
292
|
+
await sleep(100)
|
|
293
|
+
|
|
294
|
+
// Second poll - should have the answer
|
|
295
|
+
const after = await userC.api.poll(0)
|
|
296
|
+
assert.ok(after.answers.length > initialAnswerCount, 'Should have new answer after polling')
|
|
297
|
+
|
|
298
|
+
const found = after.answers.find((a: any) => a.offerId === offerId)
|
|
299
|
+
assert.ok(found, 'Should find our answer')
|
|
300
|
+
assert.strictEqual(found.sdp, 'poll-test-answer', 'Should have correct SDP')
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it('should support since parameter', async () => {
|
|
304
|
+
const timestamp = Date.now()
|
|
305
|
+
|
|
306
|
+
// Poll with recent timestamp - should get no old answers
|
|
307
|
+
const result = await userC.api.poll(timestamp)
|
|
308
|
+
|
|
309
|
+
// All returned answers should be after the timestamp
|
|
310
|
+
for (const answer of result.answers) {
|
|
311
|
+
assert.ok(answer.answeredAt >= timestamp - 1000, 'Answers should be after since timestamp')
|
|
312
|
+
}
|
|
313
|
+
})
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
describe('7. Ownership', () => {
|
|
317
|
+
let offerId: string
|
|
318
|
+
|
|
319
|
+
before(async () => {
|
|
320
|
+
// userA publishes offer for ownership tests
|
|
321
|
+
const result = await userA.api.publish({
|
|
322
|
+
tags: ['ownership-test'],
|
|
323
|
+
offers: [{ sdp: 'ownership-test-sdp' }],
|
|
324
|
+
ttl: 120000
|
|
325
|
+
})
|
|
326
|
+
offerId = result.offers[0].offerId
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
it('should allow owner to delete offer', async () => {
|
|
330
|
+
await userA.api.deleteOffer(offerId)
|
|
331
|
+
assert.ok(true, 'Should successfully delete own offer')
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
it('should reject delete from non-owner', async () => {
|
|
335
|
+
// First publish a new offer as userA
|
|
336
|
+
const newOffer = await userA.api.publish({
|
|
337
|
+
tags: ['ownership-test-2'],
|
|
338
|
+
offers: [{ sdp: 'another-sdp' }],
|
|
339
|
+
ttl: 120000
|
|
340
|
+
})
|
|
341
|
+
const newOfferId = newOffer.offers[0].offerId
|
|
342
|
+
|
|
343
|
+
// Try to delete as userB (non-owner)
|
|
344
|
+
await assert.rejects(
|
|
345
|
+
async () => {
|
|
346
|
+
await userB.api.deleteOffer(newOfferId)
|
|
347
|
+
},
|
|
348
|
+
/OWNERSHIP_MISMATCH|NOT_AUTHORIZED|forbidden|owner/i,
|
|
349
|
+
'Should reject delete from non-owner'
|
|
350
|
+
)
|
|
351
|
+
})
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
describe('8. TTL/Expiry', () => {
|
|
355
|
+
it('should enforce minimum TTL', async () => {
|
|
356
|
+
// Server enforces minimum TTL of 60 seconds
|
|
357
|
+
// Publish with short TTL - server should apply minimum
|
|
358
|
+
const result = await userA.api.publish({
|
|
359
|
+
tags: ['ttl-test-min'],
|
|
360
|
+
offers: [{ sdp: 'ttl-test-sdp' }],
|
|
361
|
+
ttl: 1000 // Request 1 second, but server enforces 60s minimum
|
|
362
|
+
})
|
|
363
|
+
const offerId = result.offers[0].offerId
|
|
364
|
+
|
|
365
|
+
// Offer should have expiresAt at least 60 seconds in the future
|
|
366
|
+
const offer = result.offers[0]
|
|
367
|
+
const now = Date.now()
|
|
368
|
+
const minExpiry = now + 55000 // Allow 5s buffer for timing
|
|
369
|
+
|
|
370
|
+
assert.ok(offer.expiresAt >= minExpiry, 'Server should enforce minimum TTL of ~60 seconds')
|
|
371
|
+
|
|
372
|
+
// Should be discoverable
|
|
373
|
+
const discoveryResult = await rpc('discover', { tags: ['ttl-test-min'], limit: 10 }) as any
|
|
374
|
+
const found = discoveryResult.offers?.find((o: any) => o.offerId === offerId)
|
|
375
|
+
assert.ok(found, 'Should find offer with minimum TTL applied')
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
it('should respect TTL when publishing', async () => {
|
|
379
|
+
// Publish with valid TTL (2 minutes)
|
|
380
|
+
const ttl = 120000
|
|
381
|
+
const result = await userA.api.publish({
|
|
382
|
+
tags: ['ttl-test-valid'],
|
|
383
|
+
offers: [{ sdp: 'ttl-valid-sdp' }],
|
|
384
|
+
ttl
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
const offer = result.offers[0]
|
|
388
|
+
const now = Date.now()
|
|
389
|
+
|
|
390
|
+
// expiresAt should be approximately now + ttl
|
|
391
|
+
assert.ok(offer.expiresAt >= now + ttl - 5000, 'Should expire at approximately now + TTL')
|
|
392
|
+
assert.ok(offer.expiresAt <= now + ttl + 5000, 'Should expire at approximately now + TTL')
|
|
393
|
+
})
|
|
394
|
+
})
|
|
395
|
+
})
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration test setup and helpers for api.ronde.vu
|
|
3
|
+
* Implements HMAC-SHA256 authentication directly (not dependent on client library)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Buffer } from 'node:buffer'
|
|
7
|
+
import * as crypto from 'node:crypto'
|
|
8
|
+
|
|
9
|
+
const API_URL = process.env.API_URL || 'https://api.ronde.vu'
|
|
10
|
+
|
|
11
|
+
export interface Credential {
|
|
12
|
+
name: string
|
|
13
|
+
secret: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Convert hex string to bytes
|
|
18
|
+
*/
|
|
19
|
+
function hexToBytes(hex: string): Uint8Array {
|
|
20
|
+
const match = hex.match(/.{1,2}/g)
|
|
21
|
+
if (!match) throw new Error('Invalid hex string')
|
|
22
|
+
return new Uint8Array(match.map(byte => parseInt(byte, 16)))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Generate HMAC-SHA256 signature
|
|
27
|
+
*/
|
|
28
|
+
async function generateSignature(secret: string, message: string): Promise<string> {
|
|
29
|
+
const secretBytes = hexToBytes(secret)
|
|
30
|
+
const key = await globalThis.crypto.subtle.importKey(
|
|
31
|
+
'raw',
|
|
32
|
+
secretBytes,
|
|
33
|
+
{ name: 'HMAC', hash: 'SHA-256' },
|
|
34
|
+
false,
|
|
35
|
+
['sign']
|
|
36
|
+
)
|
|
37
|
+
const encoder = new TextEncoder()
|
|
38
|
+
const messageBytes = encoder.encode(message)
|
|
39
|
+
const signatureBytes = await globalThis.crypto.subtle.sign('HMAC', key, messageBytes)
|
|
40
|
+
return Buffer.from(signatureBytes).toString('base64')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Build auth headers for authenticated RPC calls
|
|
45
|
+
*/
|
|
46
|
+
async function buildAuthHeaders(
|
|
47
|
+
credential: Credential,
|
|
48
|
+
method: string,
|
|
49
|
+
params: any
|
|
50
|
+
): Promise<Record<string, string>> {
|
|
51
|
+
const timestamp = Date.now()
|
|
52
|
+
const nonce = crypto.randomUUID()
|
|
53
|
+
const paramsStr = params ? JSON.stringify(params) : '{}'
|
|
54
|
+
const message = `${timestamp}:${nonce}:${method}:${paramsStr}`
|
|
55
|
+
const signature = await generateSignature(credential.secret, message)
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
'X-Name': credential.name,
|
|
59
|
+
'X-Timestamp': timestamp.toString(),
|
|
60
|
+
'X-Nonce': nonce,
|
|
61
|
+
'X-Signature': signature
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Make an RPC call to the API (authenticated or unauthenticated)
|
|
67
|
+
*/
|
|
68
|
+
export async function rpc<T = any>(
|
|
69
|
+
method: string,
|
|
70
|
+
params: any,
|
|
71
|
+
credential?: Credential
|
|
72
|
+
): Promise<T> {
|
|
73
|
+
const headers: Record<string, string> = {
|
|
74
|
+
'Content-Type': 'application/json'
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Add auth headers if credential provided
|
|
78
|
+
if (credential) {
|
|
79
|
+
const authHeaders = await buildAuthHeaders(credential, method, params)
|
|
80
|
+
Object.assign(headers, authHeaders)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const body = JSON.stringify([{ method, params }])
|
|
84
|
+
|
|
85
|
+
const response = await fetch(`${API_URL}/rpc`, {
|
|
86
|
+
method: 'POST',
|
|
87
|
+
headers,
|
|
88
|
+
body
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
const results = await response.json() as Array<{ success: boolean; result?: T; error?: string; errorCode?: string }>
|
|
92
|
+
|
|
93
|
+
if (!Array.isArray(results) || results.length === 0) {
|
|
94
|
+
throw new Error('Invalid response from API')
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const res = results[0]
|
|
98
|
+
|
|
99
|
+
if (!res.success || res.error) {
|
|
100
|
+
throw new Error(`RPC Error: ${res.error || 'Unknown error'} (${res.errorCode || 'UNKNOWN'})`)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return res.result as T
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Generate new credentials from the API
|
|
108
|
+
*/
|
|
109
|
+
export async function generateCredentials(): Promise<Credential> {
|
|
110
|
+
const result = await rpc<{ name: string; secret: string }>('generateCredentials', {})
|
|
111
|
+
return result
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Simple authenticated API wrapper for tests
|
|
116
|
+
*/
|
|
117
|
+
class TestAPI {
|
|
118
|
+
constructor(private credential: Credential) {}
|
|
119
|
+
|
|
120
|
+
async publish(params: { tags: string[], offers: { sdp: string }[], ttl: number }) {
|
|
121
|
+
return rpc('publishOffer', params, this.credential)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async discover(params: { tags: string[], limit?: number, offset?: number }) {
|
|
125
|
+
return rpc('discover', params, this.credential)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async answerOffer(offerId: string, sdp: string) {
|
|
129
|
+
return rpc('answerOffer', { offerId, sdp }, this.credential)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async addOfferIceCandidates(offerId: string, candidates: any[]) {
|
|
133
|
+
return rpc('addIceCandidates', { offerId, candidates }, this.credential)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async getOfferIceCandidates(offerId: string, since: number) {
|
|
137
|
+
return rpc('getIceCandidates', { offerId, since }, this.credential)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async poll(since: number) {
|
|
141
|
+
return rpc('poll', { since }, this.credential)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async deleteOffer(offerId: string) {
|
|
145
|
+
return rpc('deleteOffer', { offerId }, this.credential)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Create a test context with fresh credentials and API wrapper
|
|
151
|
+
*/
|
|
152
|
+
export async function createTestContext() {
|
|
153
|
+
const credential = await generateCredentials()
|
|
154
|
+
const api = new TestAPI(credential)
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
credential,
|
|
158
|
+
api,
|
|
159
|
+
apiUrl: API_URL
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Sleep for a given number of milliseconds
|
|
165
|
+
*/
|
|
166
|
+
export function sleep(ms: number): Promise<void> {
|
|
167
|
+
return new Promise(resolve => setTimeout(resolve, ms))
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export { API_URL }
|
package/wrangler.toml
CHANGED
|
@@ -1,43 +1,42 @@
|
|
|
1
1
|
name = "rondevu"
|
|
2
2
|
main = "src/worker.ts"
|
|
3
3
|
compatibility_date = "2024-01-01"
|
|
4
|
+
# Required for crypto and better-sqlite3 compatibility
|
|
4
5
|
compatibility_flags = ["nodejs_compat"]
|
|
6
|
+
workers_dev = true
|
|
7
|
+
preview_urls = true
|
|
5
8
|
|
|
6
|
-
|
|
9
|
+
routes = [{ pattern = "test.ronde.vu/*", zone_name = "ronde.vu" }]
|
|
10
|
+
|
|
11
|
+
# Cleanup runs every 5 minutes
|
|
12
|
+
[triggers]
|
|
13
|
+
crons = ["*/5 * * * *"]
|
|
14
|
+
|
|
15
|
+
# D1 SQLite database
|
|
16
|
+
# Get your database_id: npx wrangler d1 create rondevu-db
|
|
7
17
|
[[d1_databases]]
|
|
8
18
|
binding = "DB"
|
|
9
|
-
database_name = "rondevu-offers"
|
|
10
19
|
database_id = "3d469855-d37f-477b-b139-fa58843a54ff"
|
|
11
20
|
|
|
12
|
-
# Environment variables
|
|
13
21
|
[vars]
|
|
14
|
-
OFFER_DEFAULT_TTL = "60000"
|
|
15
|
-
OFFER_MAX_TTL = "86400000"
|
|
16
|
-
OFFER_MIN_TTL = "60000"
|
|
17
|
-
MAX_OFFERS_PER_REQUEST = "100"
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
# Build configuration
|
|
22
|
+
OFFER_DEFAULT_TTL = "60000"
|
|
23
|
+
OFFER_MAX_TTL = "86400000"
|
|
24
|
+
OFFER_MIN_TTL = "60000"
|
|
25
|
+
MAX_OFFERS_PER_REQUEST = "100"
|
|
26
|
+
CORS_ORIGINS = "*"
|
|
27
|
+
VERSION = "0.5.2"
|
|
28
|
+
|
|
29
|
+
# MASTER_ENCRYPTION_KEY must be set as a secret:
|
|
30
|
+
# npx wrangler secret put MASTER_ENCRYPTION_KEY
|
|
31
|
+
# Generate with: openssl rand -hex 32
|
|
32
|
+
|
|
27
33
|
[build]
|
|
28
34
|
command = ""
|
|
29
35
|
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
|
|
34
|
-
# For production deployment:
|
|
35
|
-
# 1. Create D1 database: npx wrangler d1 create rondevu-sessions
|
|
36
|
-
# 2. Update the 'database_id' field above with the returned ID
|
|
37
|
-
# 3. Initialize schema: npx wrangler d1 execute rondevu-sessions --remote --file=./migrations/schema.sql
|
|
38
|
-
# 4. Deploy: npx wrangler deploy
|
|
36
|
+
# Development: npx wrangler dev
|
|
37
|
+
# Deploy: npx wrangler deploy
|
|
38
|
+
# Init DB: npx wrangler d1 execute rondevu-db --remote --file=./migrations/schema.sql
|
|
39
39
|
|
|
40
|
-
[observability]
|
|
41
40
|
[observability.logs]
|
|
42
41
|
enabled = true
|
|
43
42
|
head_sampling_rate = 1
|