@xtr-dev/rondevu-server 0.0.1 → 0.1.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/API.md +39 -9
- package/CLAUDE.md +47 -0
- package/README.md +144 -187
- package/build.js +12 -0
- package/dist/index.js +799 -266
- package/dist/index.js.map +4 -4
- package/migrations/0001_add_peer_id.sql +21 -0
- package/migrations/0002_remove_topics.sql +22 -0
- package/migrations/0003_remove_origin.sql +29 -0
- package/migrations/0004_add_secret.sql +4 -0
- package/migrations/schema.sql +18 -0
- package/package.json +4 -3
- package/src/app.ts +421 -127
- package/src/bloom.ts +66 -0
- package/src/config.ts +27 -2
- package/src/crypto.ts +149 -0
- package/src/index.ts +28 -12
- package/src/middleware/auth.ts +51 -0
- package/src/storage/d1.ts +394 -0
- package/src/storage/hash-id.ts +37 -0
- package/src/storage/sqlite.ts +323 -178
- package/src/storage/types.ts +128 -54
- package/src/worker.ts +51 -16
- package/wrangler.toml +45 -0
- package/DEPLOYMENT.md +0 -346
- package/src/storage/kv.ts +0 -241
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import { Storage, Offer, IceCandidate, CreateOfferRequest, TopicInfo } from './types.ts';
|
|
2
|
+
import { generateOfferHash } from './hash-id.ts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* D1 storage adapter for topic-based offer management using Cloudflare D1
|
|
6
|
+
* NOTE: This implementation is a placeholder and needs to be fully tested
|
|
7
|
+
*/
|
|
8
|
+
export class D1Storage implements Storage {
|
|
9
|
+
private db: D1Database;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Creates a new D1 storage instance
|
|
13
|
+
* @param db D1Database instance from Cloudflare Workers environment
|
|
14
|
+
*/
|
|
15
|
+
constructor(db: D1Database) {
|
|
16
|
+
this.db = db;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Initializes database schema with new topic-based structure
|
|
21
|
+
* This should be run once during setup, not on every request
|
|
22
|
+
*/
|
|
23
|
+
async initializeDatabase(): Promise<void> {
|
|
24
|
+
await this.db.exec(`
|
|
25
|
+
CREATE TABLE IF NOT EXISTS offers (
|
|
26
|
+
id TEXT PRIMARY KEY,
|
|
27
|
+
peer_id TEXT NOT NULL,
|
|
28
|
+
sdp TEXT NOT NULL,
|
|
29
|
+
created_at INTEGER NOT NULL,
|
|
30
|
+
expires_at INTEGER NOT NULL,
|
|
31
|
+
last_seen INTEGER NOT NULL,
|
|
32
|
+
secret TEXT,
|
|
33
|
+
answerer_peer_id TEXT,
|
|
34
|
+
answer_sdp TEXT,
|
|
35
|
+
answered_at INTEGER
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
CREATE INDEX IF NOT EXISTS idx_offers_peer ON offers(peer_id);
|
|
39
|
+
CREATE INDEX IF NOT EXISTS idx_offers_expires ON offers(expires_at);
|
|
40
|
+
CREATE INDEX IF NOT EXISTS idx_offers_last_seen ON offers(last_seen);
|
|
41
|
+
CREATE INDEX IF NOT EXISTS idx_offers_answerer ON offers(answerer_peer_id);
|
|
42
|
+
|
|
43
|
+
CREATE TABLE IF NOT EXISTS offer_topics (
|
|
44
|
+
offer_id TEXT NOT NULL,
|
|
45
|
+
topic TEXT NOT NULL,
|
|
46
|
+
PRIMARY KEY (offer_id, topic),
|
|
47
|
+
FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
CREATE INDEX IF NOT EXISTS idx_topics_topic ON offer_topics(topic);
|
|
51
|
+
CREATE INDEX IF NOT EXISTS idx_topics_offer ON offer_topics(offer_id);
|
|
52
|
+
|
|
53
|
+
CREATE TABLE IF NOT EXISTS ice_candidates (
|
|
54
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
55
|
+
offer_id TEXT NOT NULL,
|
|
56
|
+
peer_id TEXT NOT NULL,
|
|
57
|
+
role TEXT NOT NULL CHECK(role IN ('offerer', 'answerer')),
|
|
58
|
+
candidate TEXT NOT NULL, -- JSON: RTCIceCandidateInit object
|
|
59
|
+
created_at INTEGER NOT NULL,
|
|
60
|
+
FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
CREATE INDEX IF NOT EXISTS idx_ice_offer ON ice_candidates(offer_id);
|
|
64
|
+
CREATE INDEX IF NOT EXISTS idx_ice_peer ON ice_candidates(peer_id);
|
|
65
|
+
CREATE INDEX IF NOT EXISTS idx_ice_created ON ice_candidates(created_at);
|
|
66
|
+
`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async createOffers(offers: CreateOfferRequest[]): Promise<Offer[]> {
|
|
70
|
+
const created: Offer[] = [];
|
|
71
|
+
|
|
72
|
+
// D1 doesn't support true transactions yet, so we do this sequentially
|
|
73
|
+
for (const offer of offers) {
|
|
74
|
+
const id = offer.id || await generateOfferHash(offer.sdp, offer.topics);
|
|
75
|
+
const now = Date.now();
|
|
76
|
+
|
|
77
|
+
// Insert offer
|
|
78
|
+
await this.db.prepare(`
|
|
79
|
+
INSERT INTO offers (id, peer_id, sdp, created_at, expires_at, last_seen, secret)
|
|
80
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
81
|
+
`).bind(id, offer.peerId, offer.sdp, now, offer.expiresAt, now, offer.secret || null).run();
|
|
82
|
+
|
|
83
|
+
// Insert topics
|
|
84
|
+
for (const topic of offer.topics) {
|
|
85
|
+
await this.db.prepare(`
|
|
86
|
+
INSERT INTO offer_topics (offer_id, topic)
|
|
87
|
+
VALUES (?, ?)
|
|
88
|
+
`).bind(id, topic).run();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
created.push({
|
|
92
|
+
id,
|
|
93
|
+
peerId: offer.peerId,
|
|
94
|
+
sdp: offer.sdp,
|
|
95
|
+
topics: offer.topics,
|
|
96
|
+
createdAt: now,
|
|
97
|
+
expiresAt: offer.expiresAt,
|
|
98
|
+
lastSeen: now,
|
|
99
|
+
secret: offer.secret,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return created;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async getOffersByTopic(topic: string, excludePeerIds?: string[]): Promise<Offer[]> {
|
|
107
|
+
let query = `
|
|
108
|
+
SELECT DISTINCT o.*
|
|
109
|
+
FROM offers o
|
|
110
|
+
INNER JOIN offer_topics ot ON o.id = ot.offer_id
|
|
111
|
+
WHERE ot.topic = ? AND o.expires_at > ?
|
|
112
|
+
`;
|
|
113
|
+
|
|
114
|
+
const params: any[] = [topic, Date.now()];
|
|
115
|
+
|
|
116
|
+
if (excludePeerIds && excludePeerIds.length > 0) {
|
|
117
|
+
const placeholders = excludePeerIds.map(() => '?').join(',');
|
|
118
|
+
query += ` AND o.peer_id NOT IN (${placeholders})`;
|
|
119
|
+
params.push(...excludePeerIds);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
query += ' ORDER BY o.last_seen DESC';
|
|
123
|
+
|
|
124
|
+
const result = await this.db.prepare(query).bind(...params).all();
|
|
125
|
+
|
|
126
|
+
if (!result.results) {
|
|
127
|
+
return [];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return Promise.all(result.results.map(row => this.rowToOffer(row as any)));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async getOffersByPeerId(peerId: string): Promise<Offer[]> {
|
|
134
|
+
const result = await this.db.prepare(`
|
|
135
|
+
SELECT * FROM offers
|
|
136
|
+
WHERE peer_id = ? AND expires_at > ?
|
|
137
|
+
ORDER BY last_seen DESC
|
|
138
|
+
`).bind(peerId, Date.now()).all();
|
|
139
|
+
|
|
140
|
+
if (!result.results) {
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return Promise.all(result.results.map(row => this.rowToOffer(row as any)));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async getOfferById(offerId: string): Promise<Offer | null> {
|
|
148
|
+
const result = await this.db.prepare(`
|
|
149
|
+
SELECT * FROM offers
|
|
150
|
+
WHERE id = ? AND expires_at > ?
|
|
151
|
+
`).bind(offerId, Date.now()).first();
|
|
152
|
+
|
|
153
|
+
if (!result) {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return this.rowToOffer(result as any);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async deleteOffer(offerId: string, ownerPeerId: string): Promise<boolean> {
|
|
161
|
+
const result = await this.db.prepare(`
|
|
162
|
+
DELETE FROM offers
|
|
163
|
+
WHERE id = ? AND peer_id = ?
|
|
164
|
+
`).bind(offerId, ownerPeerId).run();
|
|
165
|
+
|
|
166
|
+
return (result.meta.changes || 0) > 0;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async deleteExpiredOffers(now: number): Promise<number> {
|
|
170
|
+
const result = await this.db.prepare(`
|
|
171
|
+
DELETE FROM offers WHERE expires_at < ?
|
|
172
|
+
`).bind(now).run();
|
|
173
|
+
|
|
174
|
+
return result.meta.changes || 0;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async answerOffer(
|
|
178
|
+
offerId: string,
|
|
179
|
+
answererPeerId: string,
|
|
180
|
+
answerSdp: string,
|
|
181
|
+
secret?: string
|
|
182
|
+
): Promise<{ success: boolean; error?: string }> {
|
|
183
|
+
// Check if offer exists and is not expired
|
|
184
|
+
const offer = await this.getOfferById(offerId);
|
|
185
|
+
|
|
186
|
+
if (!offer) {
|
|
187
|
+
return {
|
|
188
|
+
success: false,
|
|
189
|
+
error: 'Offer not found or expired'
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Verify secret if offer is protected
|
|
194
|
+
if (offer.secret && offer.secret !== secret) {
|
|
195
|
+
return {
|
|
196
|
+
success: false,
|
|
197
|
+
error: 'Invalid or missing secret'
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Check if offer already has an answerer
|
|
202
|
+
if (offer.answererPeerId) {
|
|
203
|
+
return {
|
|
204
|
+
success: false,
|
|
205
|
+
error: 'Offer already answered'
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Update offer with answer
|
|
210
|
+
const result = await this.db.prepare(`
|
|
211
|
+
UPDATE offers
|
|
212
|
+
SET answerer_peer_id = ?, answer_sdp = ?, answered_at = ?
|
|
213
|
+
WHERE id = ? AND answerer_peer_id IS NULL
|
|
214
|
+
`).bind(answererPeerId, answerSdp, Date.now(), offerId).run();
|
|
215
|
+
|
|
216
|
+
if ((result.meta.changes || 0) === 0) {
|
|
217
|
+
return {
|
|
218
|
+
success: false,
|
|
219
|
+
error: 'Offer already answered (race condition)'
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return { success: true };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async getAnsweredOffers(offererPeerId: string): Promise<Offer[]> {
|
|
227
|
+
const result = await this.db.prepare(`
|
|
228
|
+
SELECT * FROM offers
|
|
229
|
+
WHERE peer_id = ? AND answerer_peer_id IS NOT NULL AND expires_at > ?
|
|
230
|
+
ORDER BY answered_at DESC
|
|
231
|
+
`).bind(offererPeerId, Date.now()).all();
|
|
232
|
+
|
|
233
|
+
if (!result.results) {
|
|
234
|
+
return [];
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return Promise.all(result.results.map(row => this.rowToOffer(row as any)));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async addIceCandidates(
|
|
241
|
+
offerId: string,
|
|
242
|
+
peerId: string,
|
|
243
|
+
role: 'offerer' | 'answerer',
|
|
244
|
+
candidates: any[]
|
|
245
|
+
): Promise<number> {
|
|
246
|
+
console.log(`[D1] addIceCandidates: offerId=${offerId}, peerId=${peerId}, role=${role}, count=${candidates.length}`);
|
|
247
|
+
|
|
248
|
+
// Give each candidate a unique timestamp to avoid "since" filtering issues
|
|
249
|
+
// D1 doesn't have transactions, so insert one by one
|
|
250
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
251
|
+
const timestamp = Date.now() + i; // Ensure unique timestamps
|
|
252
|
+
await this.db.prepare(`
|
|
253
|
+
INSERT INTO ice_candidates (offer_id, peer_id, role, candidate, created_at)
|
|
254
|
+
VALUES (?, ?, ?, ?, ?)
|
|
255
|
+
`).bind(
|
|
256
|
+
offerId,
|
|
257
|
+
peerId,
|
|
258
|
+
role,
|
|
259
|
+
JSON.stringify(candidates[i]), // Store full object as JSON
|
|
260
|
+
timestamp
|
|
261
|
+
).run();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return candidates.length;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async getIceCandidates(
|
|
268
|
+
offerId: string,
|
|
269
|
+
targetRole: 'offerer' | 'answerer',
|
|
270
|
+
since?: number
|
|
271
|
+
): Promise<IceCandidate[]> {
|
|
272
|
+
let query = `
|
|
273
|
+
SELECT * FROM ice_candidates
|
|
274
|
+
WHERE offer_id = ? AND role = ?
|
|
275
|
+
`;
|
|
276
|
+
|
|
277
|
+
const params: any[] = [offerId, targetRole];
|
|
278
|
+
|
|
279
|
+
if (since !== undefined) {
|
|
280
|
+
query += ' AND created_at > ?';
|
|
281
|
+
params.push(since);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
query += ' ORDER BY created_at ASC';
|
|
285
|
+
|
|
286
|
+
console.log(`[D1] getIceCandidates query: offerId=${offerId}, targetRole=${targetRole}, since=${since}`);
|
|
287
|
+
const result = await this.db.prepare(query).bind(...params).all();
|
|
288
|
+
console.log(`[D1] getIceCandidates result: ${result.results?.length || 0} rows`);
|
|
289
|
+
|
|
290
|
+
if (!result.results) {
|
|
291
|
+
return [];
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const candidates = result.results.map((row: any) => ({
|
|
295
|
+
id: row.id,
|
|
296
|
+
offerId: row.offer_id,
|
|
297
|
+
peerId: row.peer_id,
|
|
298
|
+
role: row.role,
|
|
299
|
+
candidate: JSON.parse(row.candidate), // Parse JSON back to object
|
|
300
|
+
createdAt: row.created_at,
|
|
301
|
+
}));
|
|
302
|
+
|
|
303
|
+
if (candidates.length > 0) {
|
|
304
|
+
console.log(`[D1] First candidate createdAt: ${candidates[0].createdAt}, since: ${since}`);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return candidates;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async getTopics(limit: number, offset: number, startsWith?: string): Promise<{
|
|
311
|
+
topics: TopicInfo[];
|
|
312
|
+
total: number;
|
|
313
|
+
}> {
|
|
314
|
+
const now = Date.now();
|
|
315
|
+
|
|
316
|
+
// Build WHERE clause for startsWith filter
|
|
317
|
+
const whereClause = startsWith
|
|
318
|
+
? 'o.expires_at > ? AND ot.topic LIKE ?'
|
|
319
|
+
: 'o.expires_at > ?';
|
|
320
|
+
|
|
321
|
+
const startsWithPattern = startsWith ? `${startsWith}%` : null;
|
|
322
|
+
|
|
323
|
+
// Get total count of topics with active offers
|
|
324
|
+
const countQuery = `
|
|
325
|
+
SELECT COUNT(DISTINCT ot.topic) as count
|
|
326
|
+
FROM offer_topics ot
|
|
327
|
+
INNER JOIN offers o ON ot.offer_id = o.id
|
|
328
|
+
WHERE ${whereClause}
|
|
329
|
+
`;
|
|
330
|
+
|
|
331
|
+
const countStmt = this.db.prepare(countQuery);
|
|
332
|
+
const countResult = startsWith
|
|
333
|
+
? await countStmt.bind(now, startsWithPattern).first()
|
|
334
|
+
: await countStmt.bind(now).first();
|
|
335
|
+
|
|
336
|
+
const total = (countResult as any)?.count || 0;
|
|
337
|
+
|
|
338
|
+
// Get topics with peer counts (paginated)
|
|
339
|
+
const topicsQuery = `
|
|
340
|
+
SELECT
|
|
341
|
+
ot.topic,
|
|
342
|
+
COUNT(DISTINCT o.peer_id) as active_peers
|
|
343
|
+
FROM offer_topics ot
|
|
344
|
+
INNER JOIN offers o ON ot.offer_id = o.id
|
|
345
|
+
WHERE ${whereClause}
|
|
346
|
+
GROUP BY ot.topic
|
|
347
|
+
ORDER BY active_peers DESC, ot.topic ASC
|
|
348
|
+
LIMIT ? OFFSET ?
|
|
349
|
+
`;
|
|
350
|
+
|
|
351
|
+
const topicsStmt = this.db.prepare(topicsQuery);
|
|
352
|
+
const topicsResult = startsWith
|
|
353
|
+
? await topicsStmt.bind(now, startsWithPattern, limit, offset).all()
|
|
354
|
+
: await topicsStmt.bind(now, limit, offset).all();
|
|
355
|
+
|
|
356
|
+
const topics = (topicsResult.results || []).map((row: any) => ({
|
|
357
|
+
topic: row.topic,
|
|
358
|
+
activePeers: row.active_peers,
|
|
359
|
+
}));
|
|
360
|
+
|
|
361
|
+
return { topics, total };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async close(): Promise<void> {
|
|
365
|
+
// D1 doesn't require explicit connection closing
|
|
366
|
+
// Connections are managed by the Cloudflare Workers runtime
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Helper method to convert database row to Offer object with topics
|
|
371
|
+
*/
|
|
372
|
+
private async rowToOffer(row: any): Promise<Offer> {
|
|
373
|
+
// Get topics for this offer
|
|
374
|
+
const topicResult = await this.db.prepare(`
|
|
375
|
+
SELECT topic FROM offer_topics WHERE offer_id = ?
|
|
376
|
+
`).bind(row.id).all();
|
|
377
|
+
|
|
378
|
+
const topics = topicResult.results?.map((t: any) => t.topic) || [];
|
|
379
|
+
|
|
380
|
+
return {
|
|
381
|
+
id: row.id,
|
|
382
|
+
peerId: row.peer_id,
|
|
383
|
+
sdp: row.sdp,
|
|
384
|
+
topics,
|
|
385
|
+
createdAt: row.created_at,
|
|
386
|
+
expiresAt: row.expires_at,
|
|
387
|
+
lastSeen: row.last_seen,
|
|
388
|
+
secret: row.secret || undefined,
|
|
389
|
+
answererPeerId: row.answerer_peer_id || undefined,
|
|
390
|
+
answerSdp: row.answer_sdp || undefined,
|
|
391
|
+
answeredAt: row.answered_at || undefined,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generates a content-based offer ID using SHA-256 hash
|
|
3
|
+
* Creates deterministic IDs based on offer content (sdp, topics)
|
|
4
|
+
* PeerID is not included as it's inferred from authentication
|
|
5
|
+
* Uses Web Crypto API for compatibility with both Node.js and Cloudflare Workers
|
|
6
|
+
*
|
|
7
|
+
* @param sdp - The WebRTC SDP offer
|
|
8
|
+
* @param topics - Array of topic strings
|
|
9
|
+
* @returns SHA-256 hash of the sanitized offer content
|
|
10
|
+
*/
|
|
11
|
+
export async function generateOfferHash(
|
|
12
|
+
sdp: string,
|
|
13
|
+
topics: string[]
|
|
14
|
+
): Promise<string> {
|
|
15
|
+
// Sanitize and normalize the offer content
|
|
16
|
+
// Only include core offer content (not peerId - that's inferred from auth)
|
|
17
|
+
const sanitizedOffer = {
|
|
18
|
+
sdp,
|
|
19
|
+
topics: [...topics].sort(), // Sort topics for consistency
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// Create non-prettified JSON string
|
|
23
|
+
const jsonString = JSON.stringify(sanitizedOffer);
|
|
24
|
+
|
|
25
|
+
// Convert string to Uint8Array for hashing
|
|
26
|
+
const encoder = new TextEncoder();
|
|
27
|
+
const data = encoder.encode(jsonString);
|
|
28
|
+
|
|
29
|
+
// Generate SHA-256 hash
|
|
30
|
+
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
|
31
|
+
|
|
32
|
+
// Convert hash to hex string
|
|
33
|
+
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
34
|
+
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
|
35
|
+
|
|
36
|
+
return hashHex;
|
|
37
|
+
}
|