@xtr-dev/rondevu-server 0.5.18 → 0.5.22
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/docker-publish.yml +58 -0
- package/Dockerfile +21 -10
- package/build.js +10 -6
- package/dist/index.js +3 -2
- package/dist/index.js.map +2 -2
- package/migrations/fresh_schema.sql +4 -4
- package/package.json +1 -1
- package/src/rpc.ts +28 -1
- package/src/storage/d1.ts +26 -4
- package/src/storage/memory.ts +26 -0
- package/src/storage/mysql.ts +22 -0
- package/src/storage/postgres.ts +22 -0
- package/src/storage/sqlite.ts +24 -0
- package/src/storage/types.ts +7 -0
|
@@ -21,6 +21,7 @@ CREATE TABLE identities (
|
|
|
21
21
|
CREATE INDEX idx_identities_expires ON identities(expires_at);
|
|
22
22
|
|
|
23
23
|
-- Offers table (uses public_key instead of username)
|
|
24
|
+
-- Note: No foreign key to identities - auth is stateless (signature-based)
|
|
24
25
|
CREATE TABLE offers (
|
|
25
26
|
id TEXT PRIMARY KEY,
|
|
26
27
|
public_key TEXT NOT NULL, -- Owner's Ed25519 public key
|
|
@@ -32,8 +33,7 @@ CREATE TABLE offers (
|
|
|
32
33
|
answerer_public_key TEXT,
|
|
33
34
|
answer_sdp TEXT,
|
|
34
35
|
answered_at INTEGER,
|
|
35
|
-
matched_tags TEXT
|
|
36
|
-
FOREIGN KEY (public_key) REFERENCES identities(public_key) ON DELETE CASCADE
|
|
36
|
+
matched_tags TEXT -- JSON array: tags the answerer searched for
|
|
37
37
|
);
|
|
38
38
|
|
|
39
39
|
CREATE INDEX idx_offers_public_key ON offers(public_key);
|
|
@@ -42,14 +42,14 @@ CREATE INDEX idx_offers_last_seen ON offers(last_seen);
|
|
|
42
42
|
CREATE INDEX idx_offers_answerer ON offers(answerer_public_key);
|
|
43
43
|
|
|
44
44
|
-- ICE candidates table (uses public_key instead of username)
|
|
45
|
+
-- Note: No foreign key - offers may be deleted before candidates are read
|
|
45
46
|
CREATE TABLE ice_candidates (
|
|
46
47
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
47
48
|
offer_id TEXT NOT NULL,
|
|
48
49
|
public_key TEXT NOT NULL, -- Sender's Ed25519 public key
|
|
49
50
|
role TEXT NOT NULL CHECK(role IN ('offerer', 'answerer')),
|
|
50
51
|
candidate TEXT NOT NULL,
|
|
51
|
-
created_at INTEGER NOT NULL
|
|
52
|
-
FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE
|
|
52
|
+
created_at INTEGER NOT NULL
|
|
53
53
|
);
|
|
54
54
|
|
|
55
55
|
CREATE INDEX idx_ice_offer ON ice_candidates(offer_id);
|
package/package.json
CHANGED
package/src/rpc.ts
CHANGED
|
@@ -128,6 +128,10 @@ export interface DiscoverParams {
|
|
|
128
128
|
offset?: number;
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
export interface CountOffersByTagsParams {
|
|
132
|
+
tags: string[];
|
|
133
|
+
}
|
|
134
|
+
|
|
131
135
|
export interface PublishOfferParams {
|
|
132
136
|
tags: string[];
|
|
133
137
|
offers: Array<{ sdp: string }>;
|
|
@@ -300,6 +304,28 @@ const handlers: Record<string, RpcHandler> = {
|
|
|
300
304
|
};
|
|
301
305
|
},
|
|
302
306
|
|
|
307
|
+
/**
|
|
308
|
+
* Count available offers by tags
|
|
309
|
+
*/
|
|
310
|
+
async countOffersByTags(params: CountOffersByTagsParams, publicKey, timestamp, signature, storage, config, request: RpcRequest) {
|
|
311
|
+
const { tags } = params;
|
|
312
|
+
|
|
313
|
+
const tagsValidation = validateTags(tags);
|
|
314
|
+
if (!tagsValidation.valid) {
|
|
315
|
+
throw new RpcError(ErrorCodes.INVALID_TAG, tagsValidation.error || 'Invalid tags');
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const counts = await storage.countOffersByTags(tags);
|
|
319
|
+
|
|
320
|
+
// Convert Map to object for JSON serialization
|
|
321
|
+
const result: Record<string, number> = {};
|
|
322
|
+
for (const [tag, count] of counts) {
|
|
323
|
+
result[tag] = count;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return { counts: result };
|
|
327
|
+
},
|
|
328
|
+
|
|
303
329
|
/**
|
|
304
330
|
* Publish offers with tags
|
|
305
331
|
*/
|
|
@@ -540,6 +566,7 @@ const handlers: Record<string, RpcHandler> = {
|
|
|
540
566
|
answererPublicKey: offer.answererPublicKey,
|
|
541
567
|
sdp: offer.answerSdp,
|
|
542
568
|
answeredAt: offer.answeredAt,
|
|
569
|
+
matchedTags: offer.matchedTags,
|
|
543
570
|
})),
|
|
544
571
|
iceCandidates: iceCandidatesByOffer,
|
|
545
572
|
};
|
|
@@ -668,7 +695,7 @@ const handlers: Record<string, RpcHandler> = {
|
|
|
668
695
|
};
|
|
669
696
|
|
|
670
697
|
// Methods that don't require authentication
|
|
671
|
-
const UNAUTHENTICATED_METHODS = new Set(['discover']);
|
|
698
|
+
const UNAUTHENTICATED_METHODS = new Set(['discover', 'countOffersByTags']);
|
|
672
699
|
|
|
673
700
|
/**
|
|
674
701
|
* Handle RPC batch request with header-based authentication
|
package/src/storage/d1.ts
CHANGED
|
@@ -35,6 +35,7 @@ export class D1Storage implements Storage {
|
|
|
35
35
|
CREATE INDEX IF NOT EXISTS idx_identities_expires ON identities(expires_at);
|
|
36
36
|
|
|
37
37
|
-- WebRTC signaling offers with tags
|
|
38
|
+
-- Note: No foreign key to identities - auth is stateless (signature-based)
|
|
38
39
|
CREATE TABLE IF NOT EXISTS offers (
|
|
39
40
|
id TEXT PRIMARY KEY,
|
|
40
41
|
public_key TEXT NOT NULL,
|
|
@@ -46,8 +47,7 @@ export class D1Storage implements Storage {
|
|
|
46
47
|
answerer_public_key TEXT,
|
|
47
48
|
answer_sdp TEXT,
|
|
48
49
|
answered_at INTEGER,
|
|
49
|
-
matched_tags TEXT
|
|
50
|
-
FOREIGN KEY (public_key) REFERENCES identities(public_key) ON DELETE CASCADE
|
|
50
|
+
matched_tags TEXT
|
|
51
51
|
);
|
|
52
52
|
|
|
53
53
|
CREATE INDEX IF NOT EXISTS idx_offers_public_key ON offers(public_key);
|
|
@@ -56,14 +56,14 @@ export class D1Storage implements Storage {
|
|
|
56
56
|
CREATE INDEX IF NOT EXISTS idx_offers_answerer ON offers(answerer_public_key);
|
|
57
57
|
|
|
58
58
|
-- ICE candidates table
|
|
59
|
+
-- Note: No foreign key - offers may be deleted before candidates are read
|
|
59
60
|
CREATE TABLE IF NOT EXISTS ice_candidates (
|
|
60
61
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
61
62
|
offer_id TEXT NOT NULL,
|
|
62
63
|
public_key TEXT NOT NULL,
|
|
63
64
|
role TEXT NOT NULL CHECK(role IN ('offerer', 'answerer')),
|
|
64
65
|
candidate TEXT NOT NULL,
|
|
65
|
-
created_at INTEGER NOT NULL
|
|
66
|
-
FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE
|
|
66
|
+
created_at INTEGER NOT NULL
|
|
67
67
|
);
|
|
68
68
|
|
|
69
69
|
CREATE INDEX IF NOT EXISTS idx_ice_offer ON ice_candidates(offer_id);
|
|
@@ -508,6 +508,28 @@ export class D1Storage implements Storage {
|
|
|
508
508
|
return result?.count ?? 0;
|
|
509
509
|
}
|
|
510
510
|
|
|
511
|
+
async countOffersByTags(tags: string[]): Promise<Map<string, number>> {
|
|
512
|
+
const result = new Map<string, number>();
|
|
513
|
+
if (tags.length === 0) return result;
|
|
514
|
+
|
|
515
|
+
const now = Date.now();
|
|
516
|
+
|
|
517
|
+
// Query each tag individually using json_each
|
|
518
|
+
for (const tag of tags) {
|
|
519
|
+
const queryResult = await this.db.prepare(`
|
|
520
|
+
SELECT COUNT(DISTINCT o.id) as count
|
|
521
|
+
FROM offers o, json_each(o.tags) as t
|
|
522
|
+
WHERE t.value = ?
|
|
523
|
+
AND o.expires_at > ?
|
|
524
|
+
AND o.answerer_public_key IS NULL
|
|
525
|
+
`).bind(tag, now).first() as { count: number } | null;
|
|
526
|
+
|
|
527
|
+
result.set(tag, queryResult?.count ?? 0);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
return result;
|
|
531
|
+
}
|
|
532
|
+
|
|
511
533
|
// ===== Helper Methods =====
|
|
512
534
|
|
|
513
535
|
private rowToOffer(row: any): Offer {
|
package/src/storage/memory.ts
CHANGED
|
@@ -445,6 +445,32 @@ export class MemoryStorage implements Storage {
|
|
|
445
445
|
return candidates ? candidates.length : 0;
|
|
446
446
|
}
|
|
447
447
|
|
|
448
|
+
async countOffersByTags(tags: string[]): Promise<Map<string, number>> {
|
|
449
|
+
const result = new Map<string, number>();
|
|
450
|
+
if (tags.length === 0) return result;
|
|
451
|
+
|
|
452
|
+
const now = Date.now();
|
|
453
|
+
|
|
454
|
+
for (const tag of tags) {
|
|
455
|
+
const offerIds = this.offersByTag.get(tag);
|
|
456
|
+
if (!offerIds) {
|
|
457
|
+
result.set(tag, 0);
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
let count = 0;
|
|
462
|
+
for (const offerId of offerIds) {
|
|
463
|
+
const offer = this.offers.get(offerId);
|
|
464
|
+
if (offer && offer.expiresAt > now && !offer.answererPublicKey) {
|
|
465
|
+
count++;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
result.set(tag, count);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
return result;
|
|
472
|
+
}
|
|
473
|
+
|
|
448
474
|
// ===== Helper Methods =====
|
|
449
475
|
|
|
450
476
|
private removeOfferFromIndexes(offer: Offer): void {
|
package/src/storage/mysql.ts
CHANGED
|
@@ -476,6 +476,28 @@ export class MySQLStorage implements Storage {
|
|
|
476
476
|
return Number(rows[0].count);
|
|
477
477
|
}
|
|
478
478
|
|
|
479
|
+
async countOffersByTags(tags: string[]): Promise<Map<string, number>> {
|
|
480
|
+
const result = new Map<string, number>();
|
|
481
|
+
if (tags.length === 0) return result;
|
|
482
|
+
|
|
483
|
+
const now = Date.now();
|
|
484
|
+
|
|
485
|
+
// Query each tag individually using JSON_CONTAINS
|
|
486
|
+
for (const tag of tags) {
|
|
487
|
+
const [rows] = await this.pool.query<RowDataPacket[]>(
|
|
488
|
+
`SELECT COUNT(DISTINCT id) as count
|
|
489
|
+
FROM offers
|
|
490
|
+
WHERE JSON_CONTAINS(tags, ?)
|
|
491
|
+
AND expires_at > ?
|
|
492
|
+
AND answerer_public_key IS NULL`,
|
|
493
|
+
[JSON.stringify(tag), now]
|
|
494
|
+
);
|
|
495
|
+
result.set(tag, Number(rows[0].count));
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return result;
|
|
499
|
+
}
|
|
500
|
+
|
|
479
501
|
// ===== Helper Methods =====
|
|
480
502
|
|
|
481
503
|
private rowToOffer(row: RowDataPacket): Offer {
|
package/src/storage/postgres.ts
CHANGED
|
@@ -484,6 +484,28 @@ export class PostgreSQLStorage implements Storage {
|
|
|
484
484
|
return Number(result.rows[0].count);
|
|
485
485
|
}
|
|
486
486
|
|
|
487
|
+
async countOffersByTags(tags: string[]): Promise<Map<string, number>> {
|
|
488
|
+
const result = new Map<string, number>();
|
|
489
|
+
if (tags.length === 0) return result;
|
|
490
|
+
|
|
491
|
+
const now = Date.now();
|
|
492
|
+
|
|
493
|
+
// Query each tag individually using JSONB containment
|
|
494
|
+
for (const tag of tags) {
|
|
495
|
+
const queryResult = await this.pool.query(
|
|
496
|
+
`SELECT COUNT(DISTINCT id) as count
|
|
497
|
+
FROM offers
|
|
498
|
+
WHERE tags ? $1
|
|
499
|
+
AND expires_at > $2
|
|
500
|
+
AND answerer_public_key IS NULL`,
|
|
501
|
+
[tag, now]
|
|
502
|
+
);
|
|
503
|
+
result.set(tag, Number(queryResult.rows[0].count));
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
return result;
|
|
507
|
+
}
|
|
508
|
+
|
|
487
509
|
// ===== Helper Methods =====
|
|
488
510
|
|
|
489
511
|
private rowToOffer(row: any): Offer {
|
package/src/storage/sqlite.ts
CHANGED
|
@@ -543,6 +543,30 @@ export class SQLiteStorage implements Storage {
|
|
|
543
543
|
return result.count;
|
|
544
544
|
}
|
|
545
545
|
|
|
546
|
+
async countOffersByTags(tags: string[]): Promise<Map<string, number>> {
|
|
547
|
+
const result = new Map<string, number>();
|
|
548
|
+
if (tags.length === 0) return result;
|
|
549
|
+
|
|
550
|
+
const now = Date.now();
|
|
551
|
+
|
|
552
|
+
// Query counts for each tag individually for accuracy
|
|
553
|
+
// (an offer with multiple matching tags should only count once per tag)
|
|
554
|
+
const stmt = this.db.prepare(`
|
|
555
|
+
SELECT COUNT(DISTINCT o.id) as count
|
|
556
|
+
FROM offers o, json_each(o.tags) as t
|
|
557
|
+
WHERE t.value = ?
|
|
558
|
+
AND o.expires_at > ?
|
|
559
|
+
AND o.answerer_public_key IS NULL
|
|
560
|
+
`);
|
|
561
|
+
|
|
562
|
+
for (const tag of tags) {
|
|
563
|
+
const row = stmt.get(tag, now) as { count: number };
|
|
564
|
+
result.set(tag, row.count);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
return result;
|
|
568
|
+
}
|
|
569
|
+
|
|
546
570
|
// ===== Helper Methods =====
|
|
547
571
|
|
|
548
572
|
/**
|
package/src/storage/types.ts
CHANGED
|
@@ -255,4 +255,11 @@ export interface Storage {
|
|
|
255
255
|
* @returns ICE candidate count for offer
|
|
256
256
|
*/
|
|
257
257
|
getIceCandidateCount(offerId: string): Promise<number>;
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Counts available (unanswered, non-expired) offers for each of the given tags
|
|
261
|
+
* @param tags Array of tags to count offers for
|
|
262
|
+
* @returns Map of tag to offer count
|
|
263
|
+
*/
|
|
264
|
+
countOffersByTags(tags: string[]): Promise<Map<string, number>>;
|
|
258
265
|
}
|