@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.
@@ -1,51 +1,32 @@
1
- -- Fresh schema for Rondevu v0.5.0+
2
- -- Unified Ed25519 authentication - username/keypair only
3
- -- This is the complete schema without migration steps
1
+ -- Fresh schema for Rondevu (Tags-based)
2
+ -- Offers are standalone with tags for discovery
4
3
 
5
4
  -- Drop existing tables if they exist
6
5
  DROP TABLE IF EXISTS ice_candidates;
7
- DROP TABLE IF EXISTS services;
8
6
  DROP TABLE IF EXISTS offers;
9
- DROP TABLE IF EXISTS usernames;
10
-
11
- -- Usernames table (now required for all users, even anonymous)
12
- CREATE TABLE usernames (
13
- username TEXT PRIMARY KEY,
14
- public_key TEXT NOT NULL UNIQUE,
15
- claimed_at INTEGER NOT NULL,
16
- expires_at INTEGER NOT NULL,
17
- last_used INTEGER NOT NULL,
18
- metadata TEXT,
19
- CHECK(length(username) >= 3 AND length(username) <= 32)
20
- );
21
-
22
- CREATE INDEX idx_usernames_expires ON usernames(expires_at);
23
- CREATE INDEX idx_usernames_public_key ON usernames(public_key);
7
+ DROP TABLE IF EXISTS services;
8
+ DROP TABLE IF EXISTS credentials;
9
+ DROP TABLE IF EXISTS rate_limits;
10
+ DROP TABLE IF EXISTS nonces;
24
11
 
25
- -- Services table with discovery fields
26
- CREATE TABLE services (
27
- id TEXT PRIMARY KEY,
28
- service_fqn TEXT NOT NULL,
29
- service_name TEXT NOT NULL,
30
- version TEXT NOT NULL,
31
- username TEXT NOT NULL,
12
+ -- Credentials table (name + secret auth)
13
+ CREATE TABLE credentials (
14
+ name TEXT PRIMARY KEY,
15
+ secret TEXT NOT NULL UNIQUE,
32
16
  created_at INTEGER NOT NULL,
33
17
  expires_at INTEGER NOT NULL,
34
- FOREIGN KEY (username) REFERENCES usernames(username) ON DELETE CASCADE,
35
- UNIQUE(service_name, version, username)
18
+ last_used INTEGER NOT NULL,
19
+ CHECK(length(name) >= 3 AND length(name) <= 32)
36
20
  );
37
21
 
38
- CREATE INDEX idx_services_fqn ON services(service_fqn);
39
- CREATE INDEX idx_services_discovery ON services(service_name, version);
40
- CREATE INDEX idx_services_username ON services(username);
41
- CREATE INDEX idx_services_expires ON services(expires_at);
22
+ CREATE INDEX idx_credentials_expires ON credentials(expires_at);
23
+ CREATE INDEX idx_credentials_secret ON credentials(secret);
42
24
 
43
- -- Offers table (now uses username instead of peer_id)
25
+ -- Offers table (standalone with tags for discovery)
44
26
  CREATE TABLE offers (
45
27
  id TEXT PRIMARY KEY,
46
28
  username TEXT NOT NULL,
47
- service_id TEXT,
48
- service_fqn TEXT,
29
+ tags TEXT NOT NULL, -- JSON array: '["tag1", "tag2"]'
49
30
  sdp TEXT NOT NULL,
50
31
  created_at INTEGER NOT NULL,
51
32
  expires_at INTEGER NOT NULL,
@@ -53,17 +34,15 @@ CREATE TABLE offers (
53
34
  answerer_username TEXT,
54
35
  answer_sdp TEXT,
55
36
  answered_at INTEGER,
56
- FOREIGN KEY (username) REFERENCES usernames(username) ON DELETE CASCADE,
57
- FOREIGN KEY (answerer_username) REFERENCES usernames(username) ON DELETE SET NULL
37
+ FOREIGN KEY (username) REFERENCES credentials(name) ON DELETE CASCADE
58
38
  );
59
39
 
60
40
  CREATE INDEX idx_offers_username ON offers(username);
61
- CREATE INDEX idx_offers_service ON offers(service_id);
62
41
  CREATE INDEX idx_offers_expires ON offers(expires_at);
63
42
  CREATE INDEX idx_offers_last_seen ON offers(last_seen);
64
43
  CREATE INDEX idx_offers_answerer ON offers(answerer_username);
65
44
 
66
- -- ICE candidates table (now uses username instead of peer_id)
45
+ -- ICE candidates table
67
46
  CREATE TABLE ice_candidates (
68
47
  id INTEGER PRIMARY KEY AUTOINCREMENT,
69
48
  offer_id TEXT NOT NULL,
@@ -71,11 +50,27 @@ CREATE TABLE ice_candidates (
71
50
  role TEXT NOT NULL CHECK(role IN ('offerer', 'answerer')),
72
51
  candidate TEXT NOT NULL,
73
52
  created_at INTEGER NOT NULL,
74
- FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE,
75
- FOREIGN KEY (username) REFERENCES usernames(username) ON DELETE CASCADE
53
+ FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE
76
54
  );
77
55
 
78
56
  CREATE INDEX idx_ice_offer ON ice_candidates(offer_id);
79
57
  CREATE INDEX idx_ice_username ON ice_candidates(username);
80
58
  CREATE INDEX idx_ice_role ON ice_candidates(role);
81
59
  CREATE INDEX idx_ice_created ON ice_candidates(created_at);
60
+
61
+ -- Rate limits table
62
+ CREATE TABLE rate_limits (
63
+ identifier TEXT PRIMARY KEY,
64
+ count INTEGER NOT NULL,
65
+ reset_time INTEGER NOT NULL
66
+ );
67
+
68
+ CREATE INDEX idx_rate_limits_reset ON rate_limits(reset_time);
69
+
70
+ -- Nonces table (for replay attack prevention)
71
+ CREATE TABLE nonces (
72
+ nonce_key TEXT PRIMARY KEY,
73
+ expires_at INTEGER NOT NULL
74
+ );
75
+
76
+ CREATE INDEX idx_nonces_expires ON nonces(expires_at);
package/package.json CHANGED
@@ -1,29 +1,35 @@
1
1
  {
2
2
  "name": "@xtr-dev/rondevu-server",
3
- "version": "0.5.0",
4
- "description": "DNS-like WebRTC signaling server with username claiming and service discovery",
3
+ "version": "0.5.6",
4
+ "description": "DNS-like WebRTC signaling server with credential-based authentication and service discovery",
5
5
  "main": "dist/index.js",
6
6
  "scripts": {
7
7
  "build": "node build.js",
8
8
  "typecheck": "tsc",
9
9
  "dev": "ts-node src/index.ts",
10
10
  "start": "node dist/index.js",
11
- "test": "echo \"Error: no test specified\" && exit 1",
11
+ "test": "node --import tsx --test tests/integration/*.test.ts",
12
+ "test:local": "API_URL=http://localhost:3000 node --import tsx --test tests/integration/*.test.ts",
12
13
  "deploy": "npx wrangler deploy src/worker.ts --var VERSION:$(git rev-parse --short HEAD)"
13
14
  },
14
15
  "devDependencies": {
15
16
  "@cloudflare/workers-types": "^4.20251014.0",
16
17
  "@types/better-sqlite3": "^7.6.13",
17
18
  "@types/node": "^24.9.2",
19
+ "@types/pg": "^8.15.4",
18
20
  "esbuild": "^0.25.11",
19
21
  "ts-node": "^10.9.2",
22
+ "tsx": "^4.19.0",
20
23
  "typescript": "^5.9.3"
21
24
  },
22
25
  "dependencies": {
23
26
  "@hono/node-server": "^1.19.6",
24
- "@noble/ed25519": "^3.0.0",
25
27
  "@xtr-dev/rondevu-client": "^0.13.0",
26
28
  "better-sqlite3": "^12.4.1",
27
29
  "hono": "^4.10.4"
30
+ },
31
+ "optionalDependencies": {
32
+ "mysql2": "^3.14.1",
33
+ "pg": "^8.16.0"
28
34
  }
29
35
  }
package/src/app.ts CHANGED
@@ -4,9 +4,6 @@ import { Storage } from './storage/types.ts';
4
4
  import { Config } from './config.ts';
5
5
  import { handleRpc, RpcRequest } from './rpc.ts';
6
6
 
7
- // Constants
8
- const MAX_BATCH_SIZE = 100;
9
-
10
7
  /**
11
8
  * Creates the Hono application with RPC interface
12
9
  */
@@ -25,7 +22,7 @@ export function createApp(storage: Storage, config: Config) {
25
22
  return config.corsOrigins[0];
26
23
  },
27
24
  allowMethods: ['GET', 'POST', 'OPTIONS'],
28
- allowHeaders: ['Content-Type', 'Origin'],
25
+ allowHeaders: ['Content-Type', 'Origin', 'X-Name', 'X-Timestamp', 'X-Nonce', 'X-Signature'],
29
26
  exposeHeaders: ['Content-Type'],
30
27
  credentials: false,
31
28
  maxAge: 86400,
@@ -36,7 +33,7 @@ export function createApp(storage: Storage, config: Config) {
36
33
  return c.json({
37
34
  version: config.version,
38
35
  name: 'Rondevu',
39
- description: 'WebRTC signaling with RPC interface and Ed25519 authentication',
36
+ description: 'WebRTC signaling with RPC interface and HMAC signature-based authentication',
40
37
  }, 200);
41
38
  });
42
39
 
@@ -51,35 +48,58 @@ export function createApp(storage: Storage, config: Config) {
51
48
 
52
49
  /**
53
50
  * POST /rpc
54
- * RPC endpoint - accepts single or batch method calls
51
+ * RPC endpoint - accepts batch method calls only
55
52
  */
56
53
  app.post('/rpc', async (c) => {
57
54
  try {
58
55
  const body = await c.req.json();
59
56
 
60
- // Support both single request and batch array
61
- const requests: RpcRequest[] = Array.isArray(body) ? body : [body];
57
+ // Only accept batch arrays
58
+ if (!Array.isArray(body)) {
59
+ return c.json([{
60
+ success: false,
61
+ error: 'Request must be an array of RPC calls',
62
+ errorCode: 'INVALID_PARAMS'
63
+ }], 400);
64
+ }
65
+
66
+ const requests: RpcRequest[] = body;
62
67
 
63
68
  // Validate requests
64
69
  if (requests.length === 0) {
65
- return c.json({ error: 'Empty request array' }, 400);
70
+ return c.json([{
71
+ success: false,
72
+ error: 'Empty request array',
73
+ errorCode: 'INVALID_PARAMS'
74
+ }], 400);
66
75
  }
67
76
 
68
- if (requests.length > MAX_BATCH_SIZE) {
69
- return c.json({ error: `Too many requests in batch (max ${MAX_BATCH_SIZE})` }, 400);
77
+ if (requests.length > config.maxBatchSize) {
78
+ return c.json([{
79
+ success: false,
80
+ error: `Too many requests in batch (max ${config.maxBatchSize})`,
81
+ errorCode: 'BATCH_TOO_LARGE'
82
+ }], 413); // 413 Payload Too Large
70
83
  }
71
84
 
72
- // Handle RPC
73
- const responses = await handleRpc(requests, storage, config);
85
+ // Handle RPC (pass context for auth headers)
86
+ const responses = await handleRpc(requests, c, storage, config);
74
87
 
75
- // Return single response or array based on input
76
- return c.json(Array.isArray(body) ? responses : responses[0], 200);
88
+ // Always return array
89
+ return c.json(responses, 200);
77
90
  } catch (err) {
78
91
  console.error('RPC error:', err);
79
- return c.json({
92
+
93
+ // Distinguish between JSON parse errors and validation errors
94
+ const errorMsg = err instanceof SyntaxError
95
+ ? 'Invalid JSON in request body'
96
+ : 'Request must be valid JSON array';
97
+
98
+ return c.json([{
80
99
  success: false,
81
- error: 'Invalid request format',
82
- }, 400);
100
+ error: errorMsg,
101
+ errorCode: 'INVALID_PARAMS'
102
+ }], 400);
83
103
  }
84
104
  });
85
105
 
package/src/config.ts CHANGED
@@ -1,11 +1,16 @@
1
+ import { Storage } from './storage/types.ts';
2
+ import { StorageType } from './storage/factory.ts';
3
+
1
4
  /**
2
5
  * Application configuration
3
6
  * Reads from environment variables with sensible defaults
4
7
  */
5
8
  export interface Config {
6
9
  port: number;
7
- storageType: 'sqlite' | 'memory';
10
+ storageType: StorageType;
8
11
  storagePath: string;
12
+ databaseUrl: string;
13
+ dbPoolSize: number;
9
14
  corsOrigins: string[];
10
15
  version: string;
11
16
  offerDefaultTtl: number;
@@ -13,24 +18,165 @@ export interface Config {
13
18
  offerMinTtl: number;
14
19
  cleanupInterval: number;
15
20
  maxOffersPerRequest: number;
21
+ maxBatchSize: number;
22
+ maxSdpSize: number;
23
+ maxCandidateSize: number;
24
+ maxCandidateDepth: number;
25
+ maxCandidatesPerRequest: number;
26
+ maxTotalOperations: number;
27
+ timestampMaxAge: number; // Max age for timestamps (replay protection)
28
+ timestampMaxFuture: number; // Max future tolerance for timestamps (clock skew)
29
+ masterEncryptionKey: string; // 64-char hex string for encrypting secrets (32 bytes)
16
30
  }
17
31
 
18
32
  /**
19
33
  * Loads configuration from environment variables
20
34
  */
21
35
  export function loadConfig(): Config {
22
- return {
23
- port: parseInt(process.env.PORT || '3000', 10),
24
- storageType: (process.env.STORAGE_TYPE || 'sqlite') as 'sqlite' | 'memory',
36
+ // Master encryption key for secret storage
37
+ // CRITICAL: Set MASTER_ENCRYPTION_KEY in production to a secure random value
38
+ let masterEncryptionKey = process.env.MASTER_ENCRYPTION_KEY;
39
+
40
+ if (!masterEncryptionKey) {
41
+ // SECURITY: Fail fast unless explicitly in development mode
42
+ // Default to production-safe behavior if NODE_ENV is unset
43
+ const isDevelopment = process.env.NODE_ENV === 'development';
44
+
45
+ if (!isDevelopment) {
46
+ throw new Error(
47
+ 'MASTER_ENCRYPTION_KEY environment variable must be set. ' +
48
+ 'Generate with: openssl rand -hex 32\n' +
49
+ 'For development only, set NODE_ENV=development to use insecure dev key.'
50
+ );
51
+ }
52
+
53
+ // Use deterministic key ONLY in explicit development mode
54
+ // WARNING: DO NOT USE THIS IN PRODUCTION - only for local development
55
+ console.error('⚠️ WARNING: Using insecure deterministic development key');
56
+ console.error('⚠️ ONLY use NODE_ENV=development for local development');
57
+ console.error('⚠️ Generate production key with: openssl rand -hex 32');
58
+ // Random-looking dev key (not ASCII-readable to prevent accidental production use)
59
+ masterEncryptionKey = 'a3f8b9c2d1e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2';
60
+ }
61
+
62
+ // Validate master encryption key format
63
+ // NOTE: Using regex here is safe since this runs at startup, not during request processing
64
+ if (masterEncryptionKey.length !== 64 || !/^[0-9a-fA-F]{64}$/.test(masterEncryptionKey)) {
65
+ throw new Error('MASTER_ENCRYPTION_KEY must be 64-character hex string (32 bytes). Generate with: openssl rand -hex 32');
66
+ }
67
+
68
+ // Helper to safely parse and validate integer config values
69
+ function parsePositiveInt(value: string | undefined, defaultValue: string, name: string, min = 1): number {
70
+ const parsed = parseInt(value || defaultValue, 10);
71
+ if (isNaN(parsed)) {
72
+ throw new Error(`${name} must be a valid integer (got: ${value})`);
73
+ }
74
+ if (parsed < min) {
75
+ throw new Error(`${name} must be >= ${min} (got: ${parsed})`);
76
+ }
77
+ return parsed;
78
+ }
79
+
80
+ const config = {
81
+ port: parsePositiveInt(process.env.PORT, '3000', 'PORT', 1),
82
+ storageType: (process.env.STORAGE_TYPE || 'memory') as StorageType,
25
83
  storagePath: process.env.STORAGE_PATH || ':memory:',
84
+ databaseUrl: process.env.DATABASE_URL || '',
85
+ dbPoolSize: parsePositiveInt(process.env.DB_POOL_SIZE, '10', 'DB_POOL_SIZE', 1),
26
86
  corsOrigins: process.env.CORS_ORIGINS
27
87
  ? process.env.CORS_ORIGINS.split(',').map(o => o.trim())
28
88
  : ['*'],
29
89
  version: process.env.VERSION || 'unknown',
30
- offerDefaultTtl: parseInt(process.env.OFFER_DEFAULT_TTL || '60000', 10),
31
- offerMaxTtl: parseInt(process.env.OFFER_MAX_TTL || '86400000', 10),
32
- offerMinTtl: parseInt(process.env.OFFER_MIN_TTL || '60000', 10),
33
- cleanupInterval: parseInt(process.env.CLEANUP_INTERVAL || '60000', 10),
34
- maxOffersPerRequest: parseInt(process.env.MAX_OFFERS_PER_REQUEST || '100', 10)
90
+ offerDefaultTtl: parsePositiveInt(process.env.OFFER_DEFAULT_TTL, '60000', 'OFFER_DEFAULT_TTL', 1000),
91
+ offerMaxTtl: parsePositiveInt(process.env.OFFER_MAX_TTL, '86400000', 'OFFER_MAX_TTL', 1000),
92
+ offerMinTtl: parsePositiveInt(process.env.OFFER_MIN_TTL, '60000', 'OFFER_MIN_TTL', 1000),
93
+ cleanupInterval: parsePositiveInt(process.env.CLEANUP_INTERVAL, '60000', 'CLEANUP_INTERVAL', 1000),
94
+ maxOffersPerRequest: parsePositiveInt(process.env.MAX_OFFERS_PER_REQUEST, '100', 'MAX_OFFERS_PER_REQUEST', 1),
95
+ maxBatchSize: parsePositiveInt(process.env.MAX_BATCH_SIZE, '100', 'MAX_BATCH_SIZE', 1),
96
+ maxSdpSize: parsePositiveInt(process.env.MAX_SDP_SIZE, String(64 * 1024), 'MAX_SDP_SIZE', 1024), // Min 1KB
97
+ maxCandidateSize: parsePositiveInt(process.env.MAX_CANDIDATE_SIZE, String(4 * 1024), 'MAX_CANDIDATE_SIZE', 256), // Min 256 bytes
98
+ maxCandidateDepth: parsePositiveInt(process.env.MAX_CANDIDATE_DEPTH, '10', 'MAX_CANDIDATE_DEPTH', 1),
99
+ maxCandidatesPerRequest: parsePositiveInt(process.env.MAX_CANDIDATES_PER_REQUEST, '100', 'MAX_CANDIDATES_PER_REQUEST', 1),
100
+ maxTotalOperations: parsePositiveInt(process.env.MAX_TOTAL_OPERATIONS, '1000', 'MAX_TOTAL_OPERATIONS', 1),
101
+ timestampMaxAge: parsePositiveInt(process.env.TIMESTAMP_MAX_AGE, '60000', 'TIMESTAMP_MAX_AGE', 1000), // Min 1 second
102
+ timestampMaxFuture: parsePositiveInt(process.env.TIMESTAMP_MAX_FUTURE, '60000', 'TIMESTAMP_MAX_FUTURE', 1000), // Min 1 second
103
+ masterEncryptionKey,
35
104
  };
105
+
106
+ return config;
107
+ }
108
+
109
+ /**
110
+ * Default config values (shared between Node and Workers)
111
+ */
112
+ export const CONFIG_DEFAULTS = {
113
+ offerDefaultTtl: 60000,
114
+ offerMaxTtl: 86400000,
115
+ offerMinTtl: 60000,
116
+ cleanupInterval: 60000,
117
+ maxOffersPerRequest: 100,
118
+ maxBatchSize: 100,
119
+ maxSdpSize: 64 * 1024,
120
+ maxCandidateSize: 4 * 1024,
121
+ maxCandidateDepth: 10,
122
+ maxCandidatesPerRequest: 100,
123
+ maxTotalOperations: 1000,
124
+ timestampMaxAge: 60000,
125
+ timestampMaxFuture: 60000,
126
+ } as const;
127
+
128
+ /**
129
+ * Build config for Cloudflare Workers from env vars
130
+ */
131
+ export function buildWorkerConfig(env: {
132
+ MASTER_ENCRYPTION_KEY: string;
133
+ OFFER_DEFAULT_TTL?: string;
134
+ OFFER_MAX_TTL?: string;
135
+ OFFER_MIN_TTL?: string;
136
+ MAX_OFFERS_PER_REQUEST?: string;
137
+ MAX_BATCH_SIZE?: string;
138
+ CORS_ORIGINS?: string;
139
+ VERSION?: string;
140
+ }): Config {
141
+ return {
142
+ port: 0, // Not used in Workers
143
+ storageType: 'sqlite', // D1 is SQLite-compatible
144
+ storagePath: '', // Not used with D1
145
+ databaseUrl: '', // Not used with D1
146
+ dbPoolSize: 10, // Not used with D1
147
+ corsOrigins: env.CORS_ORIGINS?.split(',').map(o => o.trim()) ?? ['*'],
148
+ version: env.VERSION ?? 'unknown',
149
+ offerDefaultTtl: env.OFFER_DEFAULT_TTL ? parseInt(env.OFFER_DEFAULT_TTL, 10) : CONFIG_DEFAULTS.offerDefaultTtl,
150
+ offerMaxTtl: env.OFFER_MAX_TTL ? parseInt(env.OFFER_MAX_TTL, 10) : CONFIG_DEFAULTS.offerMaxTtl,
151
+ offerMinTtl: env.OFFER_MIN_TTL ? parseInt(env.OFFER_MIN_TTL, 10) : CONFIG_DEFAULTS.offerMinTtl,
152
+ cleanupInterval: CONFIG_DEFAULTS.cleanupInterval,
153
+ maxOffersPerRequest: env.MAX_OFFERS_PER_REQUEST ? parseInt(env.MAX_OFFERS_PER_REQUEST, 10) : CONFIG_DEFAULTS.maxOffersPerRequest,
154
+ maxBatchSize: env.MAX_BATCH_SIZE ? parseInt(env.MAX_BATCH_SIZE, 10) : CONFIG_DEFAULTS.maxBatchSize,
155
+ maxSdpSize: CONFIG_DEFAULTS.maxSdpSize,
156
+ maxCandidateSize: CONFIG_DEFAULTS.maxCandidateSize,
157
+ maxCandidateDepth: CONFIG_DEFAULTS.maxCandidateDepth,
158
+ maxCandidatesPerRequest: CONFIG_DEFAULTS.maxCandidatesPerRequest,
159
+ maxTotalOperations: CONFIG_DEFAULTS.maxTotalOperations,
160
+ timestampMaxAge: CONFIG_DEFAULTS.timestampMaxAge,
161
+ timestampMaxFuture: CONFIG_DEFAULTS.timestampMaxFuture,
162
+ masterEncryptionKey: env.MASTER_ENCRYPTION_KEY,
163
+ };
164
+ }
165
+
166
+ /**
167
+ * Run cleanup of expired entries (shared between Node and Workers)
168
+ * @returns Object with counts of deleted items
169
+ */
170
+ export async function runCleanup(storage: Storage, now: number): Promise<{
171
+ offers: number;
172
+ credentials: number;
173
+ rateLimits: number;
174
+ nonces: number;
175
+ }> {
176
+ const offers = await storage.deleteExpiredOffers(now);
177
+ const credentials = await storage.deleteExpiredCredentials(now);
178
+ const rateLimits = await storage.deleteExpiredRateLimits(now);
179
+ const nonces = await storage.deleteExpiredNonces(now);
180
+
181
+ return { offers, credentials, rateLimits, nonces };
36
182
  }