@xtr-dev/rondevu-server 0.5.1 → 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.
- 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 +2755 -1448
- 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 +155 -9
- package/src/crypto.ts +361 -263
- package/src/index.ts +20 -25
- package/src/rpc.ts +658 -405
- package/src/storage/d1.ts +312 -263
- package/src/storage/factory.ts +69 -0
- package/src/storage/hash-id.ts +13 -9
- package/src/storage/memory.ts +559 -0
- package/src/storage/mysql.ts +588 -0
- package/src/storage/postgres.ts +595 -0
- package/src/storage/schemas/mysql.sql +59 -0
- package/src/storage/schemas/postgres.sql +64 -0
- package/src/storage/sqlite.ts +303 -269
- package/src/storage/types.ts +113 -113
- 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,69 @@
|
|
|
1
|
+
import { Storage } from './types.ts';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Supported storage backend types
|
|
5
|
+
*/
|
|
6
|
+
export type StorageType = 'memory' | 'sqlite' | 'mysql' | 'postgres';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Configuration for creating a storage backend
|
|
10
|
+
*/
|
|
11
|
+
export interface StorageConfig {
|
|
12
|
+
type: StorageType;
|
|
13
|
+
/** Master encryption key for secrets (64-char hex string) */
|
|
14
|
+
masterEncryptionKey: string;
|
|
15
|
+
/** SQLite database path (default: ':memory:') */
|
|
16
|
+
sqlitePath?: string;
|
|
17
|
+
/** Connection string for MySQL/PostgreSQL */
|
|
18
|
+
connectionString?: string;
|
|
19
|
+
/** Connection pool size for MySQL/PostgreSQL (default: 10) */
|
|
20
|
+
poolSize?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Creates a storage backend based on configuration
|
|
25
|
+
* Uses dynamic imports to avoid loading unused dependencies
|
|
26
|
+
*/
|
|
27
|
+
export async function createStorage(config: StorageConfig): Promise<Storage> {
|
|
28
|
+
switch (config.type) {
|
|
29
|
+
case 'memory': {
|
|
30
|
+
const { MemoryStorage } = await import('./memory.ts');
|
|
31
|
+
return new MemoryStorage(config.masterEncryptionKey);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
case 'sqlite': {
|
|
35
|
+
const { SQLiteStorage } = await import('./sqlite.ts');
|
|
36
|
+
return new SQLiteStorage(
|
|
37
|
+
config.sqlitePath || ':memory:',
|
|
38
|
+
config.masterEncryptionKey
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
case 'mysql': {
|
|
43
|
+
if (!config.connectionString) {
|
|
44
|
+
throw new Error('MySQL storage requires DATABASE_URL connection string');
|
|
45
|
+
}
|
|
46
|
+
const { MySQLStorage } = await import('./mysql.ts');
|
|
47
|
+
return MySQLStorage.create(
|
|
48
|
+
config.connectionString,
|
|
49
|
+
config.masterEncryptionKey,
|
|
50
|
+
config.poolSize || 10
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
case 'postgres': {
|
|
55
|
+
if (!config.connectionString) {
|
|
56
|
+
throw new Error('PostgreSQL storage requires DATABASE_URL connection string');
|
|
57
|
+
}
|
|
58
|
+
const { PostgreSQLStorage } = await import('./postgres.ts');
|
|
59
|
+
return PostgreSQLStorage.create(
|
|
60
|
+
config.connectionString,
|
|
61
|
+
config.masterEncryptionKey,
|
|
62
|
+
config.poolSize || 10
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
default:
|
|
67
|
+
throw new Error(`Unsupported storage type: ${config.type}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
package/src/storage/hash-id.ts
CHANGED
|
@@ -1,21 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Generates a
|
|
3
|
-
*
|
|
4
|
-
* PeerID is not included as it's inferred from authentication
|
|
2
|
+
* Generates a unique offer ID using SHA-256 hash
|
|
3
|
+
* Combines SDP content with timestamp and random bytes for uniqueness
|
|
5
4
|
* Uses Web Crypto API for compatibility with both Node.js and Cloudflare Workers
|
|
6
5
|
*
|
|
7
6
|
* @param sdp - The WebRTC SDP offer
|
|
8
|
-
* @returns SHA-256 hash
|
|
7
|
+
* @returns Unique SHA-256 hash ID
|
|
9
8
|
*/
|
|
10
9
|
export async function generateOfferHash(sdp: string): Promise<string> {
|
|
11
|
-
//
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
|
|
10
|
+
// Generate random bytes for uniqueness (8 bytes = 64 bits of randomness)
|
|
11
|
+
const randomBytes = crypto.getRandomValues(new Uint8Array(8));
|
|
12
|
+
const randomHex = Array.from(randomBytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
13
|
+
|
|
14
|
+
// Include SDP, timestamp, and random bytes for uniqueness
|
|
15
|
+
const hashInput = {
|
|
16
|
+
sdp,
|
|
17
|
+
timestamp: Date.now(),
|
|
18
|
+
nonce: randomHex
|
|
15
19
|
};
|
|
16
20
|
|
|
17
21
|
// Create non-prettified JSON string
|
|
18
|
-
const jsonString = JSON.stringify(
|
|
22
|
+
const jsonString = JSON.stringify(hashInput);
|
|
19
23
|
|
|
20
24
|
// Convert string to Uint8Array for hashing
|
|
21
25
|
const encoder = new TextEncoder();
|
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Storage,
|
|
3
|
+
Offer,
|
|
4
|
+
IceCandidate,
|
|
5
|
+
CreateOfferRequest,
|
|
6
|
+
Credential,
|
|
7
|
+
GenerateCredentialsRequest,
|
|
8
|
+
} from './types.ts';
|
|
9
|
+
import { generateOfferHash } from './hash-id.ts';
|
|
10
|
+
|
|
11
|
+
const YEAR_IN_MS = 365 * 24 * 60 * 60 * 1000;
|
|
12
|
+
|
|
13
|
+
interface RateLimit {
|
|
14
|
+
count: number;
|
|
15
|
+
resetTime: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface NonceEntry {
|
|
19
|
+
expiresAt: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* In-memory storage adapter for rondevu signaling system
|
|
24
|
+
* Data is not persisted - all data is lost on server restart
|
|
25
|
+
* Best for development, testing, or ephemeral deployments
|
|
26
|
+
*/
|
|
27
|
+
export class MemoryStorage implements Storage {
|
|
28
|
+
private masterEncryptionKey: string;
|
|
29
|
+
|
|
30
|
+
// Primary storage
|
|
31
|
+
private credentials = new Map<string, Credential>();
|
|
32
|
+
private offers = new Map<string, Offer>();
|
|
33
|
+
private iceCandidates = new Map<string, IceCandidate[]>(); // offerId → candidates
|
|
34
|
+
private rateLimits = new Map<string, RateLimit>();
|
|
35
|
+
private nonces = new Map<string, NonceEntry>();
|
|
36
|
+
|
|
37
|
+
// Secondary indexes for efficient lookups
|
|
38
|
+
private offersByUsername = new Map<string, Set<string>>(); // username → offer IDs
|
|
39
|
+
private offersByTag = new Map<string, Set<string>>(); // tag → offer IDs
|
|
40
|
+
private offersByAnswerer = new Map<string, Set<string>>(); // answerer username → offer IDs
|
|
41
|
+
|
|
42
|
+
// Auto-increment counter for ICE candidates
|
|
43
|
+
private iceCandidateIdCounter = 0;
|
|
44
|
+
|
|
45
|
+
constructor(masterEncryptionKey: string) {
|
|
46
|
+
this.masterEncryptionKey = masterEncryptionKey;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ===== Offer Management =====
|
|
50
|
+
|
|
51
|
+
async createOffers(offers: CreateOfferRequest[]): Promise<Offer[]> {
|
|
52
|
+
const created: Offer[] = [];
|
|
53
|
+
const now = Date.now();
|
|
54
|
+
|
|
55
|
+
for (const request of offers) {
|
|
56
|
+
const id = request.id || await generateOfferHash(request.sdp);
|
|
57
|
+
|
|
58
|
+
const offer: Offer = {
|
|
59
|
+
id,
|
|
60
|
+
username: request.username,
|
|
61
|
+
tags: request.tags,
|
|
62
|
+
sdp: request.sdp,
|
|
63
|
+
createdAt: now,
|
|
64
|
+
expiresAt: request.expiresAt,
|
|
65
|
+
lastSeen: now,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// Store offer
|
|
69
|
+
this.offers.set(id, offer);
|
|
70
|
+
|
|
71
|
+
// Update username index
|
|
72
|
+
if (!this.offersByUsername.has(request.username)) {
|
|
73
|
+
this.offersByUsername.set(request.username, new Set());
|
|
74
|
+
}
|
|
75
|
+
this.offersByUsername.get(request.username)!.add(id);
|
|
76
|
+
|
|
77
|
+
// Update tag indexes
|
|
78
|
+
for (const tag of request.tags) {
|
|
79
|
+
if (!this.offersByTag.has(tag)) {
|
|
80
|
+
this.offersByTag.set(tag, new Set());
|
|
81
|
+
}
|
|
82
|
+
this.offersByTag.get(tag)!.add(id);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
created.push(offer);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return created;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async getOffersByUsername(username: string): Promise<Offer[]> {
|
|
92
|
+
const now = Date.now();
|
|
93
|
+
const offerIds = this.offersByUsername.get(username);
|
|
94
|
+
if (!offerIds) return [];
|
|
95
|
+
|
|
96
|
+
const offers: Offer[] = [];
|
|
97
|
+
for (const id of offerIds) {
|
|
98
|
+
const offer = this.offers.get(id);
|
|
99
|
+
if (offer && offer.expiresAt > now) {
|
|
100
|
+
offers.push(offer);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return offers.sort((a, b) => b.lastSeen - a.lastSeen);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async getOfferById(offerId: string): Promise<Offer | null> {
|
|
108
|
+
const offer = this.offers.get(offerId);
|
|
109
|
+
if (!offer || offer.expiresAt <= Date.now()) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
return offer;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async deleteOffer(offerId: string, ownerUsername: string): Promise<boolean> {
|
|
116
|
+
const offer = this.offers.get(offerId);
|
|
117
|
+
if (!offer || offer.username !== ownerUsername) {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
this.removeOfferFromIndexes(offer);
|
|
122
|
+
this.offers.delete(offerId);
|
|
123
|
+
this.iceCandidates.delete(offerId);
|
|
124
|
+
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async deleteExpiredOffers(now: number): Promise<number> {
|
|
129
|
+
let count = 0;
|
|
130
|
+
|
|
131
|
+
for (const [id, offer] of this.offers) {
|
|
132
|
+
if (offer.expiresAt < now) {
|
|
133
|
+
this.removeOfferFromIndexes(offer);
|
|
134
|
+
this.offers.delete(id);
|
|
135
|
+
this.iceCandidates.delete(id);
|
|
136
|
+
count++;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return count;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async answerOffer(
|
|
144
|
+
offerId: string,
|
|
145
|
+
answererUsername: string,
|
|
146
|
+
answerSdp: string
|
|
147
|
+
): Promise<{ success: boolean; error?: string }> {
|
|
148
|
+
const offer = await this.getOfferById(offerId);
|
|
149
|
+
|
|
150
|
+
if (!offer) {
|
|
151
|
+
return { success: false, error: 'Offer not found or expired' };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (offer.answererUsername) {
|
|
155
|
+
return { success: false, error: 'Offer already answered' };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Update offer with answer
|
|
159
|
+
const now = Date.now();
|
|
160
|
+
offer.answererUsername = answererUsername;
|
|
161
|
+
offer.answerSdp = answerSdp;
|
|
162
|
+
offer.answeredAt = now;
|
|
163
|
+
|
|
164
|
+
// Update answerer index
|
|
165
|
+
if (!this.offersByAnswerer.has(answererUsername)) {
|
|
166
|
+
this.offersByAnswerer.set(answererUsername, new Set());
|
|
167
|
+
}
|
|
168
|
+
this.offersByAnswerer.get(answererUsername)!.add(offerId);
|
|
169
|
+
|
|
170
|
+
return { success: true };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async getAnsweredOffers(offererUsername: string): Promise<Offer[]> {
|
|
174
|
+
const now = Date.now();
|
|
175
|
+
const offerIds = this.offersByUsername.get(offererUsername);
|
|
176
|
+
if (!offerIds) return [];
|
|
177
|
+
|
|
178
|
+
const offers: Offer[] = [];
|
|
179
|
+
for (const id of offerIds) {
|
|
180
|
+
const offer = this.offers.get(id);
|
|
181
|
+
if (offer && offer.answererUsername && offer.expiresAt > now) {
|
|
182
|
+
offers.push(offer);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return offers.sort((a, b) => (b.answeredAt || 0) - (a.answeredAt || 0));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async getOffersAnsweredBy(answererUsername: string): Promise<Offer[]> {
|
|
190
|
+
const now = Date.now();
|
|
191
|
+
const offerIds = this.offersByAnswerer.get(answererUsername);
|
|
192
|
+
if (!offerIds) return [];
|
|
193
|
+
|
|
194
|
+
const offers: Offer[] = [];
|
|
195
|
+
for (const id of offerIds) {
|
|
196
|
+
const offer = this.offers.get(id);
|
|
197
|
+
if (offer && offer.expiresAt > now) {
|
|
198
|
+
offers.push(offer);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return offers.sort((a, b) => (b.answeredAt || 0) - (a.answeredAt || 0));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ===== Discovery =====
|
|
206
|
+
|
|
207
|
+
async discoverOffers(
|
|
208
|
+
tags: string[],
|
|
209
|
+
excludeUsername: string | null,
|
|
210
|
+
limit: number,
|
|
211
|
+
offset: number
|
|
212
|
+
): Promise<Offer[]> {
|
|
213
|
+
if (tags.length === 0) return [];
|
|
214
|
+
|
|
215
|
+
const now = Date.now();
|
|
216
|
+
const matchingOfferIds = new Set<string>();
|
|
217
|
+
|
|
218
|
+
// Find all offers matching any tag (OR logic)
|
|
219
|
+
for (const tag of tags) {
|
|
220
|
+
const offerIds = this.offersByTag.get(tag);
|
|
221
|
+
if (offerIds) {
|
|
222
|
+
for (const id of offerIds) {
|
|
223
|
+
matchingOfferIds.add(id);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Filter and collect matching offers
|
|
229
|
+
const offers: Offer[] = [];
|
|
230
|
+
for (const id of matchingOfferIds) {
|
|
231
|
+
const offer = this.offers.get(id);
|
|
232
|
+
if (
|
|
233
|
+
offer &&
|
|
234
|
+
offer.expiresAt > now &&
|
|
235
|
+
!offer.answererUsername &&
|
|
236
|
+
(!excludeUsername || offer.username !== excludeUsername)
|
|
237
|
+
) {
|
|
238
|
+
offers.push(offer);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Sort by created_at descending and apply pagination
|
|
243
|
+
offers.sort((a, b) => b.createdAt - a.createdAt);
|
|
244
|
+
return offers.slice(offset, offset + limit);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async getRandomOffer(
|
|
248
|
+
tags: string[],
|
|
249
|
+
excludeUsername: string | null
|
|
250
|
+
): Promise<Offer | null> {
|
|
251
|
+
if (tags.length === 0) return null;
|
|
252
|
+
|
|
253
|
+
const now = Date.now();
|
|
254
|
+
const matchingOffers: Offer[] = [];
|
|
255
|
+
|
|
256
|
+
// Find all offers matching any tag (OR logic)
|
|
257
|
+
const matchingOfferIds = new Set<string>();
|
|
258
|
+
for (const tag of tags) {
|
|
259
|
+
const offerIds = this.offersByTag.get(tag);
|
|
260
|
+
if (offerIds) {
|
|
261
|
+
for (const id of offerIds) {
|
|
262
|
+
matchingOfferIds.add(id);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Collect matching offers
|
|
268
|
+
for (const id of matchingOfferIds) {
|
|
269
|
+
const offer = this.offers.get(id);
|
|
270
|
+
if (
|
|
271
|
+
offer &&
|
|
272
|
+
offer.expiresAt > now &&
|
|
273
|
+
!offer.answererUsername &&
|
|
274
|
+
(!excludeUsername || offer.username !== excludeUsername)
|
|
275
|
+
) {
|
|
276
|
+
matchingOffers.push(offer);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (matchingOffers.length === 0) return null;
|
|
281
|
+
|
|
282
|
+
// Return random offer
|
|
283
|
+
const randomIndex = Math.floor(Math.random() * matchingOffers.length);
|
|
284
|
+
return matchingOffers[randomIndex];
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ===== ICE Candidate Management =====
|
|
288
|
+
|
|
289
|
+
async addIceCandidates(
|
|
290
|
+
offerId: string,
|
|
291
|
+
username: string,
|
|
292
|
+
role: 'offerer' | 'answerer',
|
|
293
|
+
candidates: any[]
|
|
294
|
+
): Promise<number> {
|
|
295
|
+
const baseTimestamp = Date.now();
|
|
296
|
+
|
|
297
|
+
if (!this.iceCandidates.has(offerId)) {
|
|
298
|
+
this.iceCandidates.set(offerId, []);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const candidateList = this.iceCandidates.get(offerId)!;
|
|
302
|
+
|
|
303
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
304
|
+
const candidate: IceCandidate = {
|
|
305
|
+
id: ++this.iceCandidateIdCounter,
|
|
306
|
+
offerId,
|
|
307
|
+
username,
|
|
308
|
+
role,
|
|
309
|
+
candidate: candidates[i],
|
|
310
|
+
createdAt: baseTimestamp + i,
|
|
311
|
+
};
|
|
312
|
+
candidateList.push(candidate);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return candidates.length;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async getIceCandidates(
|
|
319
|
+
offerId: string,
|
|
320
|
+
targetRole: 'offerer' | 'answerer',
|
|
321
|
+
since?: number
|
|
322
|
+
): Promise<IceCandidate[]> {
|
|
323
|
+
const candidates = this.iceCandidates.get(offerId) || [];
|
|
324
|
+
|
|
325
|
+
return candidates
|
|
326
|
+
.filter(c => c.role === targetRole && (since === undefined || c.createdAt > since))
|
|
327
|
+
.sort((a, b) => a.createdAt - b.createdAt);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async getIceCandidatesForMultipleOffers(
|
|
331
|
+
offerIds: string[],
|
|
332
|
+
username: string,
|
|
333
|
+
since?: number
|
|
334
|
+
): Promise<Map<string, IceCandidate[]>> {
|
|
335
|
+
const result = new Map<string, IceCandidate[]>();
|
|
336
|
+
|
|
337
|
+
if (offerIds.length === 0) return result;
|
|
338
|
+
if (offerIds.length > 1000) {
|
|
339
|
+
throw new Error('Too many offer IDs (max 1000)');
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
for (const offerId of offerIds) {
|
|
343
|
+
const offer = this.offers.get(offerId);
|
|
344
|
+
if (!offer) continue;
|
|
345
|
+
|
|
346
|
+
const candidates = this.iceCandidates.get(offerId) || [];
|
|
347
|
+
|
|
348
|
+
// Determine which role's candidates to return
|
|
349
|
+
// If user is offerer, return answerer candidates and vice versa
|
|
350
|
+
const isOfferer = offer.username === username;
|
|
351
|
+
const isAnswerer = offer.answererUsername === username;
|
|
352
|
+
|
|
353
|
+
if (!isOfferer && !isAnswerer) continue;
|
|
354
|
+
|
|
355
|
+
const targetRole = isOfferer ? 'answerer' : 'offerer';
|
|
356
|
+
|
|
357
|
+
const filteredCandidates = candidates
|
|
358
|
+
.filter(c => c.role === targetRole && (since === undefined || c.createdAt > since))
|
|
359
|
+
.sort((a, b) => a.createdAt - b.createdAt);
|
|
360
|
+
|
|
361
|
+
if (filteredCandidates.length > 0) {
|
|
362
|
+
result.set(offerId, filteredCandidates);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return result;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ===== Credential Management =====
|
|
370
|
+
|
|
371
|
+
async generateCredentials(request: GenerateCredentialsRequest): Promise<Credential> {
|
|
372
|
+
const now = Date.now();
|
|
373
|
+
const expiresAt = request.expiresAt || (now + YEAR_IN_MS);
|
|
374
|
+
|
|
375
|
+
const { generateCredentialName, generateSecret, encryptSecret } = await import('../crypto.ts');
|
|
376
|
+
|
|
377
|
+
let name: string;
|
|
378
|
+
|
|
379
|
+
if (request.name) {
|
|
380
|
+
if (this.credentials.has(request.name)) {
|
|
381
|
+
throw new Error('Username already taken');
|
|
382
|
+
}
|
|
383
|
+
name = request.name;
|
|
384
|
+
} else {
|
|
385
|
+
let attempts = 0;
|
|
386
|
+
const maxAttempts = 100;
|
|
387
|
+
|
|
388
|
+
while (attempts < maxAttempts) {
|
|
389
|
+
name = generateCredentialName();
|
|
390
|
+
if (!this.credentials.has(name)) break;
|
|
391
|
+
attempts++;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (attempts >= maxAttempts) {
|
|
395
|
+
throw new Error(`Failed to generate unique credential name after ${maxAttempts} attempts`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const secret = generateSecret();
|
|
400
|
+
|
|
401
|
+
// Encrypt secret before storing
|
|
402
|
+
const encryptedSecret = await encryptSecret(secret, this.masterEncryptionKey);
|
|
403
|
+
|
|
404
|
+
const credential: Credential = {
|
|
405
|
+
name: name!,
|
|
406
|
+
secret: encryptedSecret,
|
|
407
|
+
createdAt: now,
|
|
408
|
+
expiresAt,
|
|
409
|
+
lastUsed: now,
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
this.credentials.set(name!, credential);
|
|
413
|
+
|
|
414
|
+
// Return plaintext secret to user
|
|
415
|
+
return {
|
|
416
|
+
...credential,
|
|
417
|
+
secret, // Return plaintext, not encrypted
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async getCredential(name: string): Promise<Credential | null> {
|
|
422
|
+
const credential = this.credentials.get(name);
|
|
423
|
+
if (!credential || credential.expiresAt <= Date.now()) {
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
try {
|
|
428
|
+
const { decryptSecret } = await import('../crypto.ts');
|
|
429
|
+
const decryptedSecret = await decryptSecret(credential.secret, this.masterEncryptionKey);
|
|
430
|
+
|
|
431
|
+
return {
|
|
432
|
+
...credential,
|
|
433
|
+
secret: decryptedSecret,
|
|
434
|
+
};
|
|
435
|
+
} catch (error) {
|
|
436
|
+
console.error(`Failed to decrypt secret for credential '${name}':`, error);
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
async updateCredentialUsage(name: string, lastUsed: number, expiresAt: number): Promise<void> {
|
|
442
|
+
const credential = this.credentials.get(name);
|
|
443
|
+
if (credential) {
|
|
444
|
+
credential.lastUsed = lastUsed;
|
|
445
|
+
credential.expiresAt = expiresAt;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async deleteExpiredCredentials(now: number): Promise<number> {
|
|
450
|
+
let count = 0;
|
|
451
|
+
for (const [name, credential] of this.credentials) {
|
|
452
|
+
if (credential.expiresAt < now) {
|
|
453
|
+
this.credentials.delete(name);
|
|
454
|
+
count++;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return count;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ===== Rate Limiting =====
|
|
461
|
+
|
|
462
|
+
async checkRateLimit(identifier: string, limit: number, windowMs: number): Promise<boolean> {
|
|
463
|
+
const now = Date.now();
|
|
464
|
+
const existing = this.rateLimits.get(identifier);
|
|
465
|
+
|
|
466
|
+
if (!existing || existing.resetTime < now) {
|
|
467
|
+
// New window or expired - reset count
|
|
468
|
+
this.rateLimits.set(identifier, {
|
|
469
|
+
count: 1,
|
|
470
|
+
resetTime: now + windowMs,
|
|
471
|
+
});
|
|
472
|
+
return true;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Increment count in existing window
|
|
476
|
+
existing.count++;
|
|
477
|
+
return existing.count <= limit;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async deleteExpiredRateLimits(now: number): Promise<number> {
|
|
481
|
+
let count = 0;
|
|
482
|
+
for (const [identifier, rateLimit] of this.rateLimits) {
|
|
483
|
+
if (rateLimit.resetTime < now) {
|
|
484
|
+
this.rateLimits.delete(identifier);
|
|
485
|
+
count++;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
return count;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// ===== Nonce Tracking (Replay Protection) =====
|
|
492
|
+
|
|
493
|
+
async checkAndMarkNonce(nonceKey: string, expiresAt: number): Promise<boolean> {
|
|
494
|
+
if (this.nonces.has(nonceKey)) {
|
|
495
|
+
return false; // Nonce already used - replay attack
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
this.nonces.set(nonceKey, { expiresAt });
|
|
499
|
+
return true; // Nonce is new - allowed
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async deleteExpiredNonces(now: number): Promise<number> {
|
|
503
|
+
let count = 0;
|
|
504
|
+
for (const [key, entry] of this.nonces) {
|
|
505
|
+
if (entry.expiresAt < now) {
|
|
506
|
+
this.nonces.delete(key);
|
|
507
|
+
count++;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
return count;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
async close(): Promise<void> {
|
|
514
|
+
// Clear all data
|
|
515
|
+
this.credentials.clear();
|
|
516
|
+
this.offers.clear();
|
|
517
|
+
this.iceCandidates.clear();
|
|
518
|
+
this.rateLimits.clear();
|
|
519
|
+
this.nonces.clear();
|
|
520
|
+
this.offersByUsername.clear();
|
|
521
|
+
this.offersByTag.clear();
|
|
522
|
+
this.offersByAnswerer.clear();
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// ===== Helper Methods =====
|
|
526
|
+
|
|
527
|
+
private removeOfferFromIndexes(offer: Offer): void {
|
|
528
|
+
// Remove from username index
|
|
529
|
+
const usernameOffers = this.offersByUsername.get(offer.username);
|
|
530
|
+
if (usernameOffers) {
|
|
531
|
+
usernameOffers.delete(offer.id);
|
|
532
|
+
if (usernameOffers.size === 0) {
|
|
533
|
+
this.offersByUsername.delete(offer.username);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Remove from tag indexes
|
|
538
|
+
for (const tag of offer.tags) {
|
|
539
|
+
const tagOffers = this.offersByTag.get(tag);
|
|
540
|
+
if (tagOffers) {
|
|
541
|
+
tagOffers.delete(offer.id);
|
|
542
|
+
if (tagOffers.size === 0) {
|
|
543
|
+
this.offersByTag.delete(tag);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Remove from answerer index
|
|
549
|
+
if (offer.answererUsername) {
|
|
550
|
+
const answererOffers = this.offersByAnswerer.get(offer.answererUsername);
|
|
551
|
+
if (answererOffers) {
|
|
552
|
+
answererOffers.delete(offer.id);
|
|
553
|
+
if (answererOffers.size === 0) {
|
|
554
|
+
this.offersByAnswerer.delete(offer.answererUsername);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|