@xtr-dev/rondevu-server 0.5.0 → 0.5.6

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.
@@ -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
- # D1 Database binding
9
+ routes = [{ pattern = "api.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" # Default offer TTL: 1 minute
15
- OFFER_MAX_TTL = "86400000" # Max offer TTL: 24 hours
16
- OFFER_MIN_TTL = "60000" # Min offer TTL: 1 minute
17
- MAX_OFFERS_PER_REQUEST = "100" # Max offers per request
18
- MAX_TOPICS_PER_OFFER = "50" # Max topics per offer
19
- CORS_ORIGINS = "*" # Comma-separated list of allowed origins
20
- VERSION = "0.4.0" # Semantic version
21
-
22
- # AUTH_SECRET should be set as a secret, not a var
23
- # Run: npx wrangler secret put AUTH_SECRET
24
- # Enter a 64-character hex string (32 bytes)
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
- # For local development:
31
- # Run: npx wrangler dev
32
- # The local D1 database will be created automatically
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