@meshwhisper/node 0.1.0 → 0.2.0

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/dist/index.js CHANGED
@@ -21,55 +21,272 @@
21
21
  // The webhook is responsible for sending the actual APNs/FCM notification.
22
22
  // The Node only sends a silent wake signal — no message content is included.
23
23
  //
24
+ // Persistence:
25
+ // Data is stored in a SQLite database (default: ./meshwhisper.db).
26
+ // Set DB_PATH to change the location. For Docker, mount a volume at /data
27
+ // and set DB_PATH=/data/meshwhisper.db so data survives container restarts.
28
+ //
24
29
  // Self-hosted: one Docker container, one VPS, everything included.
25
30
  // Foundation-hosted: same binary, run by the Foundation as public infra.
26
31
  // ============================================================
27
32
  import * as http from 'node:http';
28
33
  import * as https from 'node:https';
34
+ import * as nodeCrypto from 'node:crypto';
35
+ import * as path from 'node:path';
29
36
  import { WebSocketServer, WebSocket } from 'ws';
37
+ import Database from 'better-sqlite3';
38
+ import { FederationManager, FEDERATION_SUBPROTOCOL, loadOrCreateFederationKey, loadPeersConfig, loadBlocklist, } from './federation.js';
30
39
  // ============================================================
31
40
  // Configuration
32
41
  // ============================================================
33
42
  const PORT = parseInt(process.env.PORT ?? '8080', 10);
34
- const BLOB_TTL_HOURS = parseInt(process.env.BLOB_TTL_HOURS ?? '72', 10);
43
+ // Default 30 days. The previous 72h was tight enough that anyone offline
44
+ // for a long weekend lost messages. Storage cost is small in absolute terms
45
+ // because chaff dominates the relay's traffic, not real blobs. SDK clients
46
+ // listen on the same window to ensure blobs aren't stranded under
47
+ // destHashes the recipient stops asking for.
48
+ const BLOB_TTL_HOURS = parseInt(process.env.BLOB_TTL_HOURS ?? '720', 10);
35
49
  const MAX_BLOB_SIZE = parseInt(process.env.MAX_BLOB_SIZE ?? String(256 * 1024), 10); // 256 KB
36
50
  const MAX_BLOBS_PER_HASH = parseInt(process.env.MAX_BLOBS_PER_HASH ?? '500', 10);
37
51
  const MEDIA_TTL_HOURS = parseInt(process.env.MEDIA_TTL_HOURS ?? String(7 * 24), 10); // 7 days
52
+ const MAX_ARCHIVE_SIZE = parseInt(process.env.MAX_ARCHIVE_SIZE ?? String(12 * 1024 * 1024), 10); // 12 MB
38
53
  const MAX_MEDIA_SIZE = parseInt(process.env.MAX_MEDIA_SIZE ?? String(50 * 1024 * 1024), 10); // 50 MB
39
54
  const PRUNE_INTERVAL_MS = 5 * 60 * 1000; // prune expired blobs every 5 minutes
40
55
  const PUSH_WEBHOOK_URL = process.env.PUSH_WEBHOOK_URL ?? null;
41
- // Rate limiting (per IP, sliding window)
56
+ const DB_PATH = process.env.DB_PATH ?? './meshwhisper.db';
57
+ // Rate limiting (per IP, sliding window).
58
+ //
59
+ // Buckets are coarse on purpose — one budget per bucket per IP per minute.
60
+ // Operators tune via env vars; the defaults are deliberately generous so
61
+ // well-behaved clients almost never hit them, while still catching obvious
62
+ // abuse (scripted enumeration, blob spam, etc).
63
+ //
64
+ // TRUST_PROXY controls whether X-Forwarded-For is honoured when computing
65
+ // the per-IP key. Default OFF — a direct-exposed node would otherwise let
66
+ // an attacker spoof the header to evade limits. Set TRUST_PROXY=1 (or any
67
+ // truthy value) when running behind nginx / Caddy / Cloudflare etc., which
68
+ // is the expected production deployment shape.
42
69
  const RATE_WINDOW_MS = 60_000; // 1 minute window
43
70
  const RATE_LIMIT_MEDIA = parseInt(process.env.RATE_LIMIT_MEDIA ?? '20', 10); // uploads/min
44
- const RATE_LIMIT_DIR = parseInt(process.env.RATE_LIMIT_DIR ?? '60', 10); // registrations/min
71
+ const RATE_LIMIT_DIR = parseInt(process.env.RATE_LIMIT_DIR ?? '60', 10); // writes/min on directory/opks/policy
72
+ const RATE_LIMIT_READ = parseInt(process.env.RATE_LIMIT_READ ?? '300', 10); // reads/min (GETs)
73
+ const RATE_LIMIT_ARCHIVE = parseInt(process.env.RATE_LIMIT_ARCHIVE ?? '30', 10); // archive PUTs/min
74
+ const TRUST_PROXY = /^(1|true|yes|on)$/i.test(process.env.TRUST_PROXY ?? '');
45
75
  /** Canonical external base URL for constructing media download links.
46
76
  * Set this when the Node is behind a reverse proxy (nginx, Caddy, Cloudflare).
47
77
  * Example: BASE_URL=https://msg.myapp.com
48
78
  * If unset, the URL is inferred from the Host header (works for local dev). */
49
79
  const BASE_URL = (process.env.BASE_URL ?? '').replace(/\/$/, '');
80
+ // ============================================================
81
+ // SQLite database setup
82
+ // ============================================================
83
+ const db = new Database(DB_PATH);
84
+ // Enable WAL mode for better concurrent read performance
85
+ db.pragma('journal_mode = WAL');
86
+ db.pragma('foreign_keys = ON');
87
+ // Migrate existing prekey_bundles table if columns are missing (added for username support)
88
+ for (const col of ['username', 'namespace']) {
89
+ try {
90
+ db.exec(`ALTER TABLE prekey_bundles ADD COLUMN ${col} TEXT`);
91
+ }
92
+ catch { /* already exists */ }
93
+ }
94
+ db.exec(`
95
+ CREATE TABLE IF NOT EXISTS blobs (
96
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
97
+ dest_hash TEXT NOT NULL,
98
+ data BLOB NOT NULL,
99
+ received_at INTEGER NOT NULL
100
+ );
101
+ CREATE INDEX IF NOT EXISTS blobs_dest_hash ON blobs (dest_hash);
102
+
103
+ CREATE TABLE IF NOT EXISTS push_registrations (
104
+ dest_hash TEXT PRIMARY KEY,
105
+ token TEXT NOT NULL,
106
+ platform TEXT NOT NULL,
107
+ topic TEXT,
108
+ push_subscription TEXT
109
+ );
110
+
111
+ CREATE TABLE IF NOT EXISTS prekey_bundles (
112
+ key TEXT PRIMARY KEY,
113
+ bundle TEXT NOT NULL,
114
+ username TEXT,
115
+ namespace TEXT
116
+ );
117
+
118
+ CREATE TABLE IF NOT EXISTS media (
119
+ id TEXT PRIMARY KEY,
120
+ data BLOB NOT NULL,
121
+ stored_at INTEGER NOT NULL
122
+ );
123
+
124
+ CREATE TABLE IF NOT EXISTS opks (
125
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
126
+ identity_key TEXT NOT NULL,
127
+ opk_public TEXT NOT NULL,
128
+ stored_at INTEGER NOT NULL,
129
+ UNIQUE (identity_key, opk_public)
130
+ );
131
+ CREATE INDEX IF NOT EXISTS opks_identity_key ON opks (identity_key);
132
+
133
+ -- TOFU auth for DELETE /opks. Identity_pubkey is the user's Ed25519
134
+ -- public key hex (cross-namespace). First DELETE establishes the hash;
135
+ -- subsequent DELETEs must match.
136
+ CREATE TABLE IF NOT EXISTS opk_auth (
137
+ identity_pubkey TEXT PRIMARY KEY,
138
+ auth_hash TEXT NOT NULL,
139
+ stored_at INTEGER NOT NULL
140
+ );
141
+ CREATE UNIQUE INDEX IF NOT EXISTS prekey_username_idx
142
+ ON prekey_bundles (namespace, username)
143
+ WHERE username IS NOT NULL;
144
+
145
+ CREATE TABLE IF NOT EXISTS archives (
146
+ peer_id TEXT PRIMARY KEY,
147
+ auth_hash TEXT NOT NULL,
148
+ data BLOB NOT NULL,
149
+ stored_at INTEGER NOT NULL,
150
+ size INTEGER NOT NULL
151
+ );
152
+
153
+ -- Per-namespace policy controlling username ownership semantics.
154
+ -- 'signed-transfer' (default for unrecorded namespaces): username is sticky
155
+ -- to whichever identity first claims it; a different key cannot take it
156
+ -- over via a plain re-registration. Re-publishing with the SAME key is
157
+ -- always allowed (covers bundle refresh and key-rotation flows).
158
+ -- 'last-writer-wins': legacy/opt-in behavior — a fresh key claiming a taken
159
+ -- username silently displaces the prior owner. Useful for password-derived
160
+ -- identities where re-deriving the same key from credentials is the
161
+ -- recovery story.
162
+ CREATE TABLE IF NOT EXISTS namespace_policy (
163
+ namespace TEXT PRIMARY KEY,
164
+ username_policy TEXT NOT NULL CHECK (username_policy IN ('signed-transfer', 'last-writer-wins')),
165
+ set_at INTEGER NOT NULL
166
+ );
167
+ `);
168
+ // Prepared statements
169
+ const stmts = {
170
+ // blobs
171
+ insertBlob: db.prepare('INSERT INTO blobs (dest_hash, data, received_at) VALUES (?, ?, ?)'),
172
+ countBlobsForHash: db.prepare('SELECT COUNT(*) AS cnt FROM blobs WHERE dest_hash = ?'),
173
+ oldestBlobIdForHash: db.prepare('SELECT id FROM blobs WHERE dest_hash = ? ORDER BY id ASC LIMIT 1'),
174
+ deleteBlob: db.prepare('DELETE FROM blobs WHERE id = ?'),
175
+ pullBlobs: db.prepare('SELECT id, data FROM blobs WHERE dest_hash = ? ORDER BY id ASC'),
176
+ deleteBlobsByHash: db.prepare('DELETE FROM blobs WHERE dest_hash = ?'),
177
+ pruneBlobs: db.prepare('DELETE FROM blobs WHERE received_at < ?'),
178
+ countBlobs: db.prepare('SELECT COUNT(*) AS cnt FROM blobs'),
179
+ // push registrations
180
+ upsertPush: db.prepare(`INSERT INTO push_registrations (dest_hash, token, platform, topic, push_subscription)
181
+ VALUES (?, ?, ?, ?, ?)
182
+ ON CONFLICT(dest_hash) DO UPDATE SET
183
+ token = excluded.token,
184
+ platform = excluded.platform,
185
+ topic = excluded.topic,
186
+ push_subscription = excluded.push_subscription`),
187
+ getPush: db.prepare('SELECT token, platform, topic, push_subscription FROM push_registrations WHERE dest_hash = ?'),
188
+ deletePush: db.prepare('DELETE FROM push_registrations WHERE dest_hash = ?'),
189
+ countPush: db.prepare('SELECT COUNT(*) AS cnt FROM push_registrations'),
190
+ // prekey bundles
191
+ upsertPrekey: db.prepare(`INSERT INTO prekey_bundles (key, bundle, username, namespace) VALUES (?, ?, ?, ?)
192
+ ON CONFLICT(key) DO UPDATE SET
193
+ bundle = excluded.bundle,
194
+ username = COALESCE(excluded.username, username),
195
+ namespace = COALESCE(excluded.namespace, namespace)`),
196
+ getPrekey: db.prepare('SELECT bundle, username FROM prekey_bundles WHERE key = ?'),
197
+ getPrekeyByUsername: db.prepare('SELECT key, bundle FROM prekey_bundles WHERE namespace = ? AND username = ?'),
198
+ countPrekeys: db.prepare('SELECT COUNT(*) AS cnt FROM prekey_bundles'),
199
+ // opks
200
+ insertOpk: db.prepare('INSERT OR IGNORE INTO opks (identity_key, opk_public, stored_at) VALUES (?, ?, ?)'),
201
+ countOpksForKey: db.prepare('SELECT COUNT(*) AS cnt FROM opks WHERE identity_key = ?'),
202
+ countOpks: db.prepare('SELECT COUNT(*) AS cnt FROM opks'),
203
+ // Purges every OPK row for one (namespace, publicKey) pair. Used by the
204
+ // SDK's one-shot migration after the OPK-resurrection fix to clear out
205
+ // zombie entries that the buggy bulk re-upload left behind. The auth_hash
206
+ // check (separate TOFU table below) prevents anyone other than the
207
+ // identity owner from purging the pool.
208
+ deleteOpksForKey: db.prepare('DELETE FROM opks WHERE identity_key = ?'),
209
+ // opk_auth (TOFU per identity)
210
+ getOpkAuthHash: db.prepare('SELECT auth_hash FROM opk_auth WHERE identity_pubkey = ?'),
211
+ insertOpkAuthHash: db.prepare('INSERT OR IGNORE INTO opk_auth (identity_pubkey, auth_hash, stored_at) VALUES (?, ?, ?)'),
212
+ // media
213
+ insertMedia: db.prepare('INSERT INTO media (id, data, stored_at) VALUES (?, ?, ?)'),
214
+ getMedia: db.prepare('SELECT data, stored_at FROM media WHERE id = ?'),
215
+ deleteMedia: db.prepare('DELETE FROM media WHERE id = ?'),
216
+ pruneMedia: db.prepare('DELETE FROM media WHERE stored_at < ?'),
217
+ countMedia: db.prepare('SELECT COUNT(*) AS cnt FROM media'),
218
+ // namespace policy
219
+ getNamespacePolicy: db.prepare('SELECT username_policy FROM namespace_policy WHERE namespace = ?'),
220
+ insertNamespacePolicy: db.prepare('INSERT OR IGNORE INTO namespace_policy (namespace, username_policy, set_at) VALUES (?, ?, ?)'),
221
+ // archives
222
+ upsertArchive: db.prepare(`INSERT INTO archives (peer_id, auth_hash, data, stored_at, size)
223
+ VALUES (?, ?, ?, ?, ?)
224
+ ON CONFLICT(peer_id) DO UPDATE SET
225
+ data = excluded.data,
226
+ stored_at = excluded.stored_at,
227
+ size = excluded.size
228
+ WHERE auth_hash = excluded.auth_hash`),
229
+ getArchiveAuthHash: db.prepare('SELECT auth_hash FROM archives WHERE peer_id = ?'),
230
+ getArchiveData: db.prepare('SELECT data FROM archives WHERE peer_id = ?'),
231
+ insertArchiveFirstTime: db.prepare('INSERT OR IGNORE INTO archives (peer_id, auth_hash, data, stored_at, size) VALUES (?, ?, ?, ?, ?)'),
232
+ countArchives: db.prepare('SELECT COUNT(*) AS cnt FROM archives'),
233
+ };
234
+ // Atomic OPK claim: SELECT + DELETE in a single transaction so two concurrent
235
+ // initiators can never claim the same one-time pre-key.
236
+ const claimOpkTx = db.transaction((identityKey) => {
237
+ const row = db.prepare('SELECT id, opk_public FROM opks WHERE identity_key = ? LIMIT 1').get(identityKey);
238
+ if (!row)
239
+ return null;
240
+ db.prepare('DELETE FROM opks WHERE id = ?').run(row.id);
241
+ return row.opk_public;
242
+ });
50
243
  const rateLimitState = new Map();
51
- /** Returns true if the request is within limits, false if it should be rejected. */
244
+ /**
245
+ * Returns ok=true if the request is within limits. On rejection, retryAfterSeconds
246
+ * is the number of whole seconds until the current window rolls over (clamped to ≥1).
247
+ */
52
248
  function checkRateLimit(ip, bucket, maxPerWindow) {
53
249
  const key = `${bucket}:${ip}`;
54
250
  const now = Date.now();
55
251
  const entry = rateLimitState.get(key);
56
252
  if (!entry || now - entry.windowStart >= RATE_WINDOW_MS) {
57
253
  rateLimitState.set(key, { count: 1, windowStart: now });
58
- return true;
254
+ return { ok: true, retryAfterSeconds: 0 };
255
+ }
256
+ if (entry.count >= maxPerWindow) {
257
+ const remainingMs = RATE_WINDOW_MS - (now - entry.windowStart);
258
+ return { ok: false, retryAfterSeconds: Math.max(1, Math.ceil(remainingMs / 1000)) };
59
259
  }
60
- if (entry.count >= maxPerWindow)
61
- return false;
62
260
  entry.count++;
261
+ return { ok: true, retryAfterSeconds: 0 };
262
+ }
263
+ /**
264
+ * Apply a rate limit at the start of a request handler. On rejection writes
265
+ * a 429 with a Retry-After header and returns true (caller should `return`).
266
+ * On accept returns false (caller continues).
267
+ */
268
+ function rateLimited(req, res, bucket, maxPerWindow) {
269
+ const r = checkRateLimit(getClientIp(req), bucket, maxPerWindow);
270
+ if (r.ok)
271
+ return false;
272
+ if (metrics.rateLimitRejections[bucket] !== undefined) {
273
+ metrics.rateLimitRejections[bucket]++;
274
+ }
275
+ res.setHeader('Retry-After', String(r.retryAfterSeconds));
276
+ sendJson(res, 429, { error: 'Too many requests', retryAfter: r.retryAfterSeconds });
63
277
  return true;
64
278
  }
65
279
  function getClientIp(req) {
66
- // Respect X-Forwarded-For when behind a proxy
67
- const forwarded = req.headers['x-forwarded-for'];
68
- if (typeof forwarded === 'string')
69
- return forwarded.split(',')[0].trim();
280
+ // X-Forwarded-For is only honoured when the operator has set TRUST_PROXY,
281
+ // otherwise an attacker on a direct-exposed node could spoof the header
282
+ // to evade per-IP rate limiting.
283
+ if (TRUST_PROXY) {
284
+ const forwarded = req.headers['x-forwarded-for'];
285
+ if (typeof forwarded === 'string')
286
+ return forwarded.split(',')[0].trim();
287
+ }
70
288
  return req.socket.remoteAddress ?? 'unknown';
71
289
  }
72
- // Prune stale rate limit entries alongside blobs
73
290
  function pruneRateLimitState() {
74
291
  const cutoff = Date.now() - RATE_WINDOW_MS;
75
292
  for (const [key, entry] of rateLimitState.entries()) {
@@ -78,6 +295,28 @@ function pruneRateLimitState() {
78
295
  }
79
296
  }
80
297
  // ============================================================
298
+ // Observability counters — surfaced via the /metrics endpoint in
299
+ // Prometheus text exposition format. Cheap, in-process, reset on
300
+ // every restart (which is what Prometheus expects from counters).
301
+ // ============================================================
302
+ const NODE_STARTED_AT_MS = Date.now();
303
+ const metrics = {
304
+ httpRequestsTotal: 0,
305
+ // Indexed by Prometheus-style status family label ("2xx", "3xx", etc.)
306
+ // plus exact "429" because that's the one operators alert on most.
307
+ httpStatus: { '2xx': 0, '3xx': 0, '4xx': 0, '5xx': 0, '429': 0 },
308
+ rateLimitRejections: { dir: 0, media: 0, read: 0, archive: 0 },
309
+ websocketConnectionsTotal: 0,
310
+ };
311
+ function recordHttpStatus(status) {
312
+ metrics.httpRequestsTotal++;
313
+ if (status === 429)
314
+ metrics.httpStatus['429']++;
315
+ const family = `${Math.floor(status / 100)}xx`;
316
+ if (metrics.httpStatus[family] !== undefined)
317
+ metrics.httpStatus[family]++;
318
+ }
319
+ // ============================================================
81
320
  // Packet header constants (must match SDK wire format)
82
321
  //
83
322
  // Binary layout (all big-endian):
@@ -98,53 +337,46 @@ function readDestHash(buf) {
98
337
  const bytes = buf.subarray(DEST_HASH_OFFSET, DEST_HASH_OFFSET + DEST_HASH_LENGTH);
99
338
  return Buffer.from(bytes).toString('hex');
100
339
  }
101
- /** Map from destHash (hex) → list of queued blobs. */
102
- const blobStore = new Map();
340
+ // ============================================================
341
+ // Blob store SQLite-backed, TTL-based
342
+ // ============================================================
103
343
  function storeBlob(destHash, data) {
104
- let queue = blobStore.get(destHash);
105
- if (!queue) {
106
- queue = [];
107
- blobStore.set(destHash, queue);
108
- }
109
- if (queue.length >= MAX_BLOBS_PER_HASH) {
110
- queue.shift(); // drop oldest when full
344
+ // Enforce per-hash cap: drop oldest if full
345
+ const row = stmts.countBlobsForHash.get(destHash);
346
+ if (row.cnt >= MAX_BLOBS_PER_HASH) {
347
+ const oldest = stmts.oldestBlobIdForHash.get(destHash);
348
+ if (oldest)
349
+ stmts.deleteBlob.run(oldest.id);
111
350
  }
112
- queue.push({ data, receivedAt: Date.now() });
351
+ const result = stmts.insertBlob.run(destHash, Buffer.from(data), Date.now());
352
+ return Number(result.lastInsertRowid);
113
353
  }
114
354
  function pullBlobs(destHash) {
115
- const queue = blobStore.get(destHash);
116
- if (!queue || queue.length === 0)
355
+ const rows = stmts.pullBlobs.all(destHash);
356
+ if (rows.length === 0)
117
357
  return [];
118
- blobStore.delete(destHash);
119
- return queue.map((b) => b.data);
358
+ stmts.deleteBlobsByHash.run(destHash);
359
+ return rows.map((r) => new Uint8Array(r.data));
120
360
  }
121
361
  function pruneExpiredBlobs() {
122
362
  const cutoff = Date.now() - BLOB_TTL_HOURS * 60 * 60 * 1000;
123
- for (const [hash, queue] of blobStore.entries()) {
124
- const fresh = queue.filter((b) => b.receivedAt > cutoff);
125
- if (fresh.length === 0) {
126
- blobStore.delete(hash);
127
- // Also expire the push token for this dest hash — if no blobs have
128
- // arrived within the blob TTL window, the device has likely rotated
129
- // its dest hashes and the token is stale.
130
- pushTokens.delete(hash);
131
- }
132
- else {
133
- blobStore.set(hash, fresh);
134
- }
135
- }
363
+ stmts.pruneBlobs.run(cutoff);
136
364
  }
137
- /** Map from destHash (hex) → push registration for offline wake signals. */
138
- const pushTokens = new Map();
139
365
  function registerPushTokens(destHashes, reg) {
140
366
  for (const hash of destHashes) {
141
- pushTokens.set(hash, reg);
367
+ stmts.upsertPush.run(hash, reg.token, reg.platform, reg.topic ?? null, reg.pushSubscription ?? null);
142
368
  }
143
369
  }
144
- function deregisterPushTokens(destHashes) {
145
- for (const hash of destHashes) {
146
- pushTokens.delete(hash);
147
- }
370
+ function getPushRegistration(destHash) {
371
+ const row = stmts.getPush.get(destHash);
372
+ if (!row)
373
+ return null;
374
+ return {
375
+ token: row.token,
376
+ platform: row.platform,
377
+ ...(row.topic ? { topic: row.topic } : {}),
378
+ ...(row.push_subscription ? { pushSubscription: row.push_subscription } : {}),
379
+ };
148
380
  }
149
381
  /**
150
382
  * Fire-and-forget POST to the configured push webhook.
@@ -180,21 +412,133 @@ function notifyPush(destHash, reg) {
180
412
  }
181
413
  }
182
414
  // ============================================================
183
- // Prekey directory — in-memory, namespace-scoped
415
+ // Prekey directory — SQLite-backed, namespace-scoped
184
416
  // ============================================================
185
- /** Map from `${namespace}:${publicKeyHex}` → serialized bundle (base64). */
186
- const prekeyDirectory = new Map();
187
417
  function directoryKey(namespace, publicKey) {
188
418
  return `${namespace}:${publicKey}`;
189
419
  }
190
- function registerPrekey(namespace, publicKey, bundle) {
191
- prekeyDirectory.set(directoryKey(namespace, publicKey), bundle);
420
+ const USERNAME_RE = /^[a-z0-9_-]{3,30}$/;
421
+ function validateUsername(raw) {
422
+ const u = raw.toLowerCase().trim();
423
+ return USERNAME_RE.test(u) ? u : null;
424
+ }
425
+ function getNamespaceUsernamePolicy(namespace) {
426
+ const row = stmts.getNamespacePolicy.get(namespace);
427
+ return row?.username_policy ?? 'signed-transfer';
428
+ }
429
+ // Wire format for username-transfer authorizations. The signed bytes MUST
430
+ // match what the SDK produces; see `src/sdk/username-transfer.ts`. Bumping
431
+ // the version tag breaks old tokens — fine, transfer tokens are short-lived.
432
+ function canonicalTransferMessage(namespace, username, toPublicKeyHex, expiresAt) {
433
+ return Buffer.from([
434
+ 'meshwhisper.username-transfer.v1',
435
+ namespace,
436
+ username,
437
+ toPublicKeyHex,
438
+ String(expiresAt),
439
+ ].join('\n'), 'utf8');
440
+ }
441
+ // DER SubjectPublicKeyInfo prefix for a raw Ed25519 public key.
442
+ // Lets `nodeCrypto.createPublicKey` ingest a 32-byte key without
443
+ // pulling in an external Ed25519 library.
444
+ const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
445
+ function verifyTransferAuth(namespace, username, newOwnerPublicKeyHex, currentOwnerPublicKeyHex, auth) {
446
+ if (typeof auth.expiresAt !== 'number' || !Number.isFinite(auth.expiresAt))
447
+ return false;
448
+ if (Date.now() > auth.expiresAt)
449
+ return false;
450
+ // fromPublicKey in the request is informational; the relay binds
451
+ // verification to the actual current owner. We still cross-check
452
+ // so an obviously-mismatched token is rejected early.
453
+ if (typeof auth.fromPublicKey === 'string' &&
454
+ auth.fromPublicKey.toLowerCase() !== currentOwnerPublicKeyHex.toLowerCase()) {
455
+ return false;
456
+ }
457
+ let signatureBytes;
458
+ try {
459
+ signatureBytes = Buffer.from(auth.signature, 'base64');
460
+ }
461
+ catch {
462
+ return false;
463
+ }
464
+ if (signatureBytes.length !== 64)
465
+ return false;
466
+ let publicKeyBytes;
467
+ try {
468
+ publicKeyBytes = Buffer.from(currentOwnerPublicKeyHex, 'hex');
469
+ }
470
+ catch {
471
+ return false;
472
+ }
473
+ if (publicKeyBytes.length !== 32)
474
+ return false;
475
+ let publicKey;
476
+ try {
477
+ publicKey = nodeCrypto.createPublicKey({
478
+ key: Buffer.concat([ED25519_SPKI_PREFIX, publicKeyBytes]),
479
+ format: 'der',
480
+ type: 'spki',
481
+ });
482
+ }
483
+ catch {
484
+ return false;
485
+ }
486
+ const message = canonicalTransferMessage(namespace, username, newOwnerPublicKeyHex, auth.expiresAt);
487
+ try {
488
+ return nodeCrypto.verify(null, message, publicKey, signatureBytes);
489
+ }
490
+ catch {
491
+ return false;
492
+ }
493
+ }
494
+ const registerPrekeyTx = db.transaction((namespace, publicKey, bundle, username, transferAuth) => {
495
+ const key = directoryKey(namespace, publicKey);
496
+ if (username) {
497
+ const existing = stmts.getPrekeyByUsername.get(namespace, username);
498
+ if (existing && existing.key !== key) {
499
+ const policy = getNamespaceUsernamePolicy(namespace);
500
+ if (policy === 'last-writer-wins') {
501
+ // Legacy/opt-in: displace the prior owner without proof.
502
+ db.prepare('DELETE FROM prekey_bundles WHERE key = ?').run(existing.key);
503
+ }
504
+ else if (transferAuth) {
505
+ // Signed-transfer policy with handover token: verify the prior owner
506
+ // authorized this takeover. existing.key is `${namespace}:${publicKey}`;
507
+ // splitting on the last colon recovers the publicKey hex (which itself
508
+ // never contains a colon).
509
+ const lastColon = existing.key.lastIndexOf(':');
510
+ const currentOwnerHex = lastColon >= 0 ? existing.key.slice(lastColon + 1) : '';
511
+ const ok = verifyTransferAuth(namespace, username, publicKey, currentOwnerHex, transferAuth);
512
+ if (!ok)
513
+ return 'invalid_transfer_auth';
514
+ db.prepare('DELETE FROM prekey_bundles WHERE key = ?').run(existing.key);
515
+ }
516
+ else {
517
+ // Signed-transfer policy, no token — reject takeover.
518
+ return 'username_taken';
519
+ }
520
+ }
521
+ }
522
+ stmts.upsertPrekey.run(key, bundle, username, namespace);
523
+ return 'ok';
524
+ });
525
+ function lookupPrekeyByPublicKey(namespace, publicKey) {
526
+ const row = stmts.getPrekey.get(directoryKey(namespace, publicKey));
527
+ if (!row)
528
+ return null;
529
+ return { bundle: row.bundle, ...(row.username ? { username: row.username } : {}) };
192
530
  }
193
- function lookupPrekey(namespace, publicKey) {
194
- return prekeyDirectory.get(directoryKey(namespace, publicKey)) ?? null;
531
+ function lookupPrekeyByUsername(namespace, username) {
532
+ const row = stmts.getPrekeyByUsername.get(namespace, username);
533
+ if (!row)
534
+ return null;
535
+ // key format is "namespace:publicKey"
536
+ const publicKey = row.key.slice(namespace.length + 1);
537
+ return { bundle: row.bundle, publicKey };
195
538
  }
196
- /** Map from random media ID (hex) → encrypted media blob. */
197
- const mediaStore = new Map();
539
+ // ============================================================
540
+ // Media store SQLite-backed, TTL-based encrypted blob storage
541
+ // ============================================================
198
542
  function generateMediaId() {
199
543
  const bytes = new Uint8Array(16);
200
544
  globalThis.crypto.getRandomValues(bytes);
@@ -203,36 +547,59 @@ function generateMediaId() {
203
547
  function storeMedia(data) {
204
548
  const id = generateMediaId();
205
549
  const storedAt = Date.now();
206
- mediaStore.set(id, { data, storedAt });
550
+ stmts.insertMedia.run(id, data, storedAt);
207
551
  return { id, expiresAt: storedAt + MEDIA_TTL_HOURS * 60 * 60 * 1000 };
208
552
  }
209
553
  function fetchMedia(id) {
210
- const entry = mediaStore.get(id);
211
- if (!entry)
554
+ const row = stmts.getMedia.get(id);
555
+ if (!row)
212
556
  return null;
213
- const expiresAt = entry.storedAt + MEDIA_TTL_HOURS * 60 * 60 * 1000;
557
+ const expiresAt = row.stored_at + MEDIA_TTL_HOURS * 60 * 60 * 1000;
214
558
  if (Date.now() > expiresAt) {
215
- mediaStore.delete(id);
559
+ stmts.deleteMedia.run(id);
216
560
  return null;
217
561
  }
218
- return entry.data;
562
+ return row.data;
219
563
  }
220
564
  function pruneExpiredMedia() {
221
565
  const cutoff = Date.now() - MEDIA_TTL_HOURS * 60 * 60 * 1000;
222
- for (const [id, entry] of mediaStore.entries()) {
223
- if (entry.storedAt < cutoff)
224
- mediaStore.delete(id);
225
- }
566
+ stmts.pruneMedia.run(cutoff);
226
567
  }
227
568
  // ============================================================
228
569
  // Connected clients — map from destHash (hex) → WebSocket
570
+ // (connection state is ephemeral by nature, always in-memory)
229
571
  // ============================================================
230
572
  /** A client may register multiple dest hashes (current + previous epoch). */
231
573
  const clientsByHash = new Map();
232
574
  /** Reverse map so we can clean up on disconnect. */
233
575
  const hashesPerClient = new Map();
576
+ const activitySubscribers = new Set();
577
+ let activityBucket = { in: 0, fwd: 0, queue: 0, wake: 0, drain: 0 };
578
+ function bumpActivity(type) {
579
+ if (activitySubscribers.size === 0)
580
+ return; // no-op when nobody is watching
581
+ activityBucket[type] += 1;
582
+ }
583
+ function flushActivity() {
584
+ if (activitySubscribers.size === 0)
585
+ return;
586
+ const total = activityBucket.in + activityBucket.fwd + activityBucket.queue +
587
+ activityBucket.wake + activityBucket.drain;
588
+ if (total === 0)
589
+ return;
590
+ const payload = `data: ${JSON.stringify(activityBucket)}\n\n`;
591
+ for (const res of activitySubscribers) {
592
+ try {
593
+ res.write(payload);
594
+ }
595
+ catch { /* dead connection — cleaned on close */ }
596
+ }
597
+ activityBucket = { in: 0, fwd: 0, queue: 0, wake: 0, drain: 0 };
598
+ }
599
+ // Flush at ~4 Hz: fast enough to feel live, slow enough to obscure
600
+ // individual sends and keep CPU usage trivial.
601
+ setInterval(flushActivity, 250);
234
602
  function registerClient(ws, destHashes) {
235
- // Remove any previous registrations for this socket
236
603
  const existing = hashesPerClient.get(ws);
237
604
  if (existing) {
238
605
  for (const h of existing)
@@ -250,9 +617,8 @@ function deregisterClient(ws) {
250
617
  if (hashes) {
251
618
  for (const h of hashes)
252
619
  clientsByHash.delete(h);
253
- // Push tokens are intentionally kept after disconnect — they are needed
254
- // to wake the device when it is offline. They expire naturally alongside
255
- // the blobs in the prune cycle.
620
+ // Push registrations are intentionally kept after disconnect — they are
621
+ // needed to wake the device when it is offline.
256
622
  hashesPerClient.delete(ws);
257
623
  }
258
624
  }
@@ -262,6 +628,7 @@ function deliverQueuedBlobs(ws, destHashes) {
262
628
  for (const blob of blobs) {
263
629
  if (ws.readyState === WebSocket.OPEN) {
264
630
  ws.send(blob, { binary: true });
631
+ bumpActivity('drain');
265
632
  }
266
633
  }
267
634
  }
@@ -275,56 +642,80 @@ function handleRelayPacket(data, sender) {
275
642
  const destHash = readDestHash(data);
276
643
  if (!destHash)
277
644
  return; // malformed header
645
+ bumpActivity('in');
646
+ // Always store the blob first so it survives regardless of delivery outcome.
647
+ // This prevents a race where the recipient's WebSocket is still in the
648
+ // registry (close event not yet fired) but the peer has already called
649
+ // terminate(): we'd forward to the stale socket and the store-and-forward
650
+ // delivery on reconnect would never happen.
651
+ const blobId = storeBlob(destHash, data);
278
652
  const recipient = clientsByHash.get(destHash);
279
653
  if (recipient && recipient !== sender && recipient.readyState === WebSocket.OPEN) {
280
- // Recipient is connected — forward directly
281
- recipient.send(data, { binary: true });
654
+ // Recipient appears connected — deliver immediately AND delete from store.
655
+ // If the send fails (connection dropped between readyState check and write),
656
+ // the blob stays in the store and will be delivered on the next reconnect.
657
+ recipient.send(data, { binary: true }, (err) => {
658
+ if (!err) {
659
+ // Delivery succeeded — purge from store so it isn't delivered twice
660
+ stmts.deleteBlob.run(blobId);
661
+ bumpActivity('fwd');
662
+ }
663
+ // On error: blob remains in store for reconnect delivery
664
+ });
282
665
  }
283
666
  else {
284
- // Recipient offline (or same socket) — store for later delivery
285
- storeBlob(destHash, data);
286
- // Wake the recipient via push if they have a registered token
287
- const pushReg = pushTokens.get(destHash);
288
- if (pushReg)
667
+ // Recipient offline (or same socket) — already stored above.
668
+ bumpActivity('queue');
669
+ // Wake the recipient via push if they have a registered token.
670
+ const pushReg = getPushRegistration(destHash);
671
+ if (pushReg) {
289
672
  notifyPush(destHash, pushReg);
673
+ bumpActivity('wake');
674
+ }
675
+ else if (federation) {
676
+ // No connected client and no push registration — this destHash may
677
+ // be homed on a federated peer. Forward best-effort. The local
678
+ // store (above) is kept as a harmless safety net; it expires per
679
+ // BLOB_TTL like everything else.
680
+ federation.forwardFromLocal(data);
681
+ }
290
682
  }
291
683
  }
292
684
  // ============================================================
293
685
  // WebSocket server
294
686
  // ============================================================
295
687
  function handleWebSocketConnection(ws) {
296
- ws.on('message', (raw) => {
297
- if (raw instanceof ArrayBuffer) {
298
- handleRelayPacket(new Uint8Array(raw), ws);
299
- return;
300
- }
301
- if (Buffer.isBuffer(raw)) {
302
- handleRelayPacket(new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength), ws);
688
+ ws.on('message', (raw, isBinary) => {
689
+ // Binary frames are relay packets
690
+ if (isBinary) {
691
+ if (raw instanceof ArrayBuffer) {
692
+ handleRelayPacket(new Uint8Array(raw), ws);
693
+ }
694
+ else if (Buffer.isBuffer(raw)) {
695
+ handleRelayPacket(new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength), ws);
696
+ }
303
697
  return;
304
698
  }
305
- // JSON control message
699
+ // Text frames are JSON control messages
306
700
  try {
307
701
  const msg = JSON.parse(raw.toString());
308
702
  if (msg.type === 'hello' && Array.isArray(msg.destHashes)) {
309
703
  const hashes = msg.destHashes.filter((h) => typeof h === 'string' && /^[0-9a-f]{16}$/.test(h));
310
704
  registerClient(ws, hashes);
311
- // Register push token/subscription if provided
312
705
  if (msg.pushPlatform === 'webpush' && typeof msg.pushSubscription === 'string') {
313
- const reg = {
314
- token: msg.pushSubscription, // subscription JSON stored in token field
706
+ registerPushTokens(hashes, {
707
+ token: msg.pushSubscription,
315
708
  platform: 'webpush',
316
709
  pushSubscription: msg.pushSubscription,
317
- };
318
- registerPushTokens(hashes, reg);
710
+ });
319
711
  }
320
712
  else if (typeof msg.pushToken === 'string' && msg.pushToken &&
321
713
  (msg.pushPlatform === 'apns' || msg.pushPlatform === 'fcm')) {
322
- const reg = {
714
+ registerPushTokens(hashes, {
323
715
  token: msg.pushToken,
324
716
  platform: msg.pushPlatform,
325
717
  ...(typeof msg.pushTopic === 'string' && msg.pushTopic ? { topic: msg.pushTopic } : {}),
326
- };
327
- registerPushTokens(hashes, reg);
718
+ });
328
719
  }
329
720
  deliverQueuedBlobs(ws, hashes);
330
721
  return;
@@ -340,12 +731,8 @@ function handleWebSocketConnection(ws) {
340
731
  // Not JSON or malformed — ignore
341
732
  }
342
733
  });
343
- ws.on('close', () => {
344
- deregisterClient(ws);
345
- });
346
- ws.on('error', () => {
347
- deregisterClient(ws);
348
- });
734
+ ws.on('close', () => deregisterClient(ws));
735
+ ws.on('error', () => deregisterClient(ws));
349
736
  }
350
737
  // ============================================================
351
738
  // HTTP handler — health check and prekey directory
@@ -366,6 +753,7 @@ function sendJson(res, status, body) {
366
753
  'Access-Control-Allow-Origin': '*',
367
754
  });
368
755
  res.end(payload);
756
+ recordHttpStatus(status);
369
757
  }
370
758
  async function handleHttp(req, res) {
371
759
  const url = new URL(req.url ?? '/', `http://localhost`);
@@ -374,31 +762,122 @@ async function handleHttp(req, res) {
374
762
  if (method === 'OPTIONS') {
375
763
  res.writeHead(204, {
376
764
  'Access-Control-Allow-Origin': '*',
377
- 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
378
- 'Access-Control-Allow-Headers': 'Content-Type',
765
+ 'Access-Control-Allow-Methods': 'GET, POST, PUT, OPTIONS',
766
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
379
767
  });
380
768
  res.end();
381
769
  return;
382
770
  }
771
+ // Anonymous live activity stream (Server-Sent Events) — public, no auth.
772
+ // Emits a JSON object every ~250ms when the relay handled any traffic in
773
+ // that window. Counts only — no destination hashes, no IPs, no per-event
774
+ // metadata. Safe to expose to anyone watching the public site.
775
+ if (url.pathname === '/events' && method === 'GET') {
776
+ // Per-IP cap on SSE subscriptions. Each subscriber holds a long-lived
777
+ // connection; cap prevents a single peer from exhausting socket budget.
778
+ if (rateLimited(req, res, 'read', RATE_LIMIT_READ))
779
+ return;
780
+ res.writeHead(200, {
781
+ 'Content-Type': 'text/event-stream',
782
+ 'Cache-Control': 'no-cache, no-store',
783
+ 'Connection': 'keep-alive',
784
+ 'X-Accel-Buffering': 'no', // disable nginx buffering for SSE
785
+ 'Access-Control-Allow-Origin': '*',
786
+ });
787
+ res.write(': connected\n\n');
788
+ activitySubscribers.add(res);
789
+ const heartbeat = setInterval(() => {
790
+ try {
791
+ res.write(': hb\n\n');
792
+ }
793
+ catch { /* ignored */ }
794
+ }, 15_000);
795
+ req.on('close', () => {
796
+ clearInterval(heartbeat);
797
+ activitySubscribers.delete(res);
798
+ });
799
+ return;
800
+ }
383
801
  // Health check
384
802
  if (url.pathname === '/health' && method === 'GET') {
385
803
  sendJson(res, 200, {
386
804
  status: 'ok',
387
805
  clients: clientsByHash.size,
388
- storedBlobs: [...blobStore.values()].reduce((n, q) => n + q.length, 0),
389
- prekeyEntries: prekeyDirectory.size,
390
- pushRegistrations: pushTokens.size,
391
- mediaEntries: mediaStore.size,
806
+ storedBlobs: stmts.countBlobs.get().cnt,
807
+ prekeyEntries: stmts.countPrekeys.get().cnt,
808
+ pushRegistrations: stmts.countPush.get().cnt,
809
+ mediaEntries: stmts.countMedia.get().cnt,
810
+ opkEntries: stmts.countOpks.get().cnt,
811
+ archiveEntries: stmts.countArchives.get().cnt,
812
+ federationPeersConfigured: federation?.stats.peersConfigured ?? 0,
813
+ federationPeersConnected: federation?.connectedPeerCount() ?? 0,
814
+ });
815
+ return;
816
+ }
817
+ // Prometheus-format metrics. Includes gauges (current state) and
818
+ // counters (cumulative since boot). Not rate-limited so a scraper
819
+ // can poll on its own schedule. Operators wanting to keep this
820
+ // private should gate it at the reverse proxy.
821
+ if (url.pathname === '/metrics' && method === 'GET') {
822
+ const lines = [];
823
+ const emit = (name, help, type, value, labels) => {
824
+ lines.push(`# HELP ${name} ${help}`);
825
+ lines.push(`# TYPE ${name} ${type}`);
826
+ const labelStr = labels
827
+ ? `{${Object.entries(labels).map(([k, v]) => `${k}="${v}"`).join(',')}}`
828
+ : '';
829
+ lines.push(`${name}${labelStr} ${value}`);
830
+ };
831
+ const emitLabeled = (name, help, type, labelName, values) => {
832
+ lines.push(`# HELP ${name} ${help}`);
833
+ lines.push(`# TYPE ${name} ${type}`);
834
+ for (const [label, value] of Object.entries(values)) {
835
+ lines.push(`${name}{${labelName}="${label}"} ${value}`);
836
+ }
837
+ };
838
+ // Uptime
839
+ emit('meshwhisper_uptime_seconds', 'Seconds since this node started', 'gauge', Math.floor((Date.now() - NODE_STARTED_AT_MS) / 1000));
840
+ // Live counts (gauges)
841
+ emit('meshwhisper_clients_connected', 'Currently connected WebSocket clients', 'gauge', clientsByHash.size);
842
+ emit('meshwhisper_stored_blobs', 'Encrypted blobs currently queued for offline delivery', 'gauge', stmts.countBlobs.get().cnt);
843
+ emit('meshwhisper_prekey_entries', 'Registered prekey-bundle entries in the directory', 'gauge', stmts.countPrekeys.get().cnt);
844
+ emit('meshwhisper_push_registrations', 'Active push-notification registrations', 'gauge', stmts.countPush.get().cnt);
845
+ emit('meshwhisper_media_entries', 'Encrypted media blobs currently stored', 'gauge', stmts.countMedia.get().cnt);
846
+ emit('meshwhisper_opk_entries', 'Unused one-time prekeys in the pool', 'gauge', stmts.countOpks.get().cnt);
847
+ emit('meshwhisper_archive_entries', 'Stored per-identity encrypted archives', 'gauge', stmts.countArchives.get().cnt);
848
+ // Counters
849
+ emit('meshwhisper_http_requests_total', 'Total HTTP requests served since startup', 'counter', metrics.httpRequestsTotal);
850
+ emitLabeled('meshwhisper_http_responses_total', 'HTTP responses by status family (plus 429 broken out)', 'counter', 'status', metrics.httpStatus);
851
+ emitLabeled('meshwhisper_rate_limit_rejections_total', 'Rate-limit (429) rejections by bucket', 'counter', 'bucket', metrics.rateLimitRejections);
852
+ emit('meshwhisper_websocket_connections_total', 'Total WebSocket connections accepted since startup', 'counter', metrics.websocketConnectionsTotal);
853
+ // Federation (zeroes when federation is dormant)
854
+ emit('meshwhisper_federation_peers_configured', 'Federation peers in the allow-list', 'gauge', federation?.stats.peersConfigured ?? 0);
855
+ emit('meshwhisper_federation_peers_connected', 'Federation peers with an established handshake', 'gauge', federation?.connectedPeerCount() ?? 0);
856
+ emit('meshwhisper_federation_forwards_sent_total', 'PacketForward frames sent to peers', 'counter', federation?.stats.forwardsSentTotal ?? 0);
857
+ emit('meshwhisper_federation_forwards_received_total', 'PacketForward frames received from peers', 'counter', federation?.stats.forwardsReceivedTotal ?? 0);
858
+ emit('meshwhisper_federation_delivered_locally_total', 'Federation packets delivered to a connected local client', 'counter', federation?.stats.deliveredLocallyTotal ?? 0);
859
+ emit('meshwhisper_federation_stored_locally_total', 'Federation packets stored for a local offline device', 'counter', federation?.stats.storedLocallyTotal ?? 0);
860
+ emit('meshwhisper_federation_forwarded_onward_total', 'Federation packets forwarded to further peers', 'counter', federation?.stats.forwardedOnwardTotal ?? 0);
861
+ emit('meshwhisper_federation_drops_duplicate_total', 'Federation packets dropped by the packet-id cache', 'counter', federation?.stats.dropsDuplicateTotal ?? 0);
862
+ emit('meshwhisper_federation_drops_ttl_total', 'Federation packets dropped by hop-count exhaustion', 'counter', federation?.stats.dropsTtlTotal ?? 0);
863
+ emit('meshwhisper_federation_handshake_failures_total', 'Failed federation handshakes', 'counter', federation?.stats.handshakeFailuresTotal ?? 0);
864
+ emit('meshwhisper_federation_drops_rate_limited_total', 'Federation frames dropped by per-peer rate limiting', 'counter', federation?.stats.dropsRateLimitedTotal ?? 0);
865
+ emit('meshwhisper_federation_handshake_rejections_blocked_total', 'Handshakes rejected by the blocklist', 'counter', federation?.stats.handshakeRejectionsBlockedTotal ?? 0);
866
+ const body = lines.join('\n') + '\n';
867
+ res.writeHead(200, {
868
+ 'Content-Type': 'text/plain; version=0.0.4; charset=utf-8',
869
+ 'Content-Length': Buffer.byteLength(body),
870
+ 'Access-Control-Allow-Origin': '*',
392
871
  });
872
+ res.end(body);
873
+ recordHttpStatus(200);
393
874
  return;
394
875
  }
395
876
  // Register prekey bundle
396
- // POST /directory { namespace, publicKey, bundle }
877
+ // POST /directory { namespace, publicKey, bundle, username? }
397
878
  if (url.pathname === '/directory' && method === 'POST') {
398
- if (!checkRateLimit(getClientIp(req), 'dir', RATE_LIMIT_DIR)) {
399
- sendJson(res, 429, { error: 'Too many requests' });
879
+ if (rateLimited(req, res, 'dir', RATE_LIMIT_DIR))
400
880
  return;
401
- }
402
881
  let body;
403
882
  try {
404
883
  body = JSON.parse(await parseBody(req));
@@ -414,35 +893,254 @@ async function handleHttp(req, res) {
414
893
  sendJson(res, 400, { error: 'Missing required fields: namespace, publicKey, bundle' });
415
894
  return;
416
895
  }
417
- registerPrekey(namespace, publicKey, bundle);
896
+ let username = null;
897
+ if (typeof body.username === 'string' && body.username) {
898
+ username = validateUsername(body.username);
899
+ if (!username) {
900
+ sendJson(res, 400, { error: 'Invalid username: 3–30 chars, a–z 0–9 _ -' });
901
+ return;
902
+ }
903
+ }
904
+ let transferAuth = null;
905
+ if (body.transferAuth && typeof body.transferAuth === 'object') {
906
+ const ta = body.transferAuth;
907
+ if (typeof ta.fromPublicKey === 'string' &&
908
+ typeof ta.expiresAt === 'number' &&
909
+ typeof ta.signature === 'string') {
910
+ transferAuth = {
911
+ fromPublicKey: ta.fromPublicKey,
912
+ expiresAt: ta.expiresAt,
913
+ signature: ta.signature,
914
+ };
915
+ }
916
+ else {
917
+ sendJson(res, 400, { error: 'Invalid transferAuth shape' });
918
+ return;
919
+ }
920
+ }
921
+ const result = registerPrekeyTx(namespace, publicKey, bundle, username, transferAuth);
922
+ if (result === 'username_taken') {
923
+ sendJson(res, 409, {
924
+ error: 'Username already claimed by a different identity in this namespace',
925
+ });
926
+ return;
927
+ }
928
+ if (result === 'invalid_transfer_auth') {
929
+ sendJson(res, 403, {
930
+ error: 'transferAuth invalid: signature, expiry, or sender mismatch',
931
+ });
932
+ return;
933
+ }
418
934
  sendJson(res, 200, { ok: true });
419
935
  return;
420
936
  }
937
+ // Set or read namespace policy
938
+ // POST /namespace-policy { namespace, usernamePolicy }
939
+ // First writer wins. Once set, the policy is sticky. Re-POSTing the
940
+ // same value returns 200; a different value returns 409.
941
+ // GET /namespace-policy?namespace=
942
+ // Returns the effective policy. Falls back to defaults when no row.
943
+ if (url.pathname === '/namespace-policy' && method === 'POST') {
944
+ if (rateLimited(req, res, 'dir', RATE_LIMIT_DIR))
945
+ return;
946
+ let body;
947
+ try {
948
+ body = JSON.parse(await parseBody(req));
949
+ }
950
+ catch {
951
+ sendJson(res, 400, { error: 'Invalid JSON body' });
952
+ return;
953
+ }
954
+ const { namespace, usernamePolicy } = body;
955
+ if (typeof namespace !== 'string' || !namespace) {
956
+ sendJson(res, 400, { error: 'Missing required field: namespace' });
957
+ return;
958
+ }
959
+ if (usernamePolicy !== 'signed-transfer' && usernamePolicy !== 'last-writer-wins') {
960
+ sendJson(res, 400, {
961
+ error: "usernamePolicy must be 'signed-transfer' or 'last-writer-wins'",
962
+ });
963
+ return;
964
+ }
965
+ stmts.insertNamespacePolicy.run(namespace, usernamePolicy, Date.now());
966
+ const current = getNamespaceUsernamePolicy(namespace);
967
+ if (current !== usernamePolicy) {
968
+ sendJson(res, 409, {
969
+ error: 'Namespace policy already set to a different value',
970
+ currentPolicy: current,
971
+ });
972
+ return;
973
+ }
974
+ sendJson(res, 200, { ok: true, usernamePolicy: current });
975
+ return;
976
+ }
977
+ if (url.pathname === '/namespace-policy' && method === 'GET') {
978
+ if (rateLimited(req, res, 'read', RATE_LIMIT_READ))
979
+ return;
980
+ const namespace = url.searchParams.get('namespace');
981
+ if (!namespace) {
982
+ sendJson(res, 400, { error: 'Missing query param: namespace' });
983
+ return;
984
+ }
985
+ sendJson(res, 200, {
986
+ namespace,
987
+ usernamePolicy: getNamespaceUsernamePolicy(namespace),
988
+ });
989
+ return;
990
+ }
421
991
  // Lookup prekey bundle
422
992
  // GET /directory?namespace=&publicKey=
993
+ // GET /directory?namespace=&username=alice
423
994
  if (url.pathname === '/directory' && method === 'GET') {
995
+ // Username/publicKey lookup is the natural endpoint for an enumeration
996
+ // attempt — bucket separately from writes so a busy lookup workload
997
+ // doesn't starve registrations and vice versa.
998
+ if (rateLimited(req, res, 'read', RATE_LIMIT_READ))
999
+ return;
1000
+ const namespace = url.searchParams.get('namespace');
1001
+ const usernameParam = url.searchParams.get('username');
1002
+ const publicKeyParam = url.searchParams.get('publicKey');
1003
+ if (!namespace) {
1004
+ sendJson(res, 400, { error: 'Missing query param: namespace' });
1005
+ return;
1006
+ }
1007
+ if (usernameParam) {
1008
+ const username = validateUsername(usernameParam);
1009
+ if (!username) {
1010
+ sendJson(res, 400, { error: 'Invalid username' });
1011
+ return;
1012
+ }
1013
+ const result = lookupPrekeyByUsername(namespace, username);
1014
+ if (!result) {
1015
+ sendJson(res, 404, { error: 'User not found' });
1016
+ return;
1017
+ }
1018
+ sendJson(res, 200, { bundle: result.bundle, publicKey: result.publicKey, username });
1019
+ return;
1020
+ }
1021
+ if (!publicKeyParam) {
1022
+ sendJson(res, 400, { error: 'Missing query param: publicKey or username' });
1023
+ return;
1024
+ }
1025
+ const result = lookupPrekeyByPublicKey(namespace, publicKeyParam);
1026
+ if (!result) {
1027
+ sendJson(res, 404, { error: 'Prekey bundle not found' });
1028
+ return;
1029
+ }
1030
+ sendJson(res, 200, { bundle: result.bundle, ...(result.username ? { username: result.username } : {}) });
1031
+ return;
1032
+ }
1033
+ // Upload one-time pre-keys for a user
1034
+ // POST /opks { namespace, publicKey, opks: string[] } (base64 public keys)
1035
+ if (url.pathname === '/opks' && method === 'POST') {
1036
+ if (rateLimited(req, res, 'dir', RATE_LIMIT_DIR))
1037
+ return;
1038
+ let body;
1039
+ try {
1040
+ body = JSON.parse(await parseBody(req));
1041
+ }
1042
+ catch {
1043
+ sendJson(res, 400, { error: 'Invalid JSON body' });
1044
+ return;
1045
+ }
1046
+ const { namespace, publicKey, opks } = body;
1047
+ if (typeof namespace !== 'string' || !namespace ||
1048
+ typeof publicKey !== 'string' || !publicKey ||
1049
+ !Array.isArray(opks) || opks.length === 0) {
1050
+ sendJson(res, 400, { error: 'Missing required fields: namespace, publicKey, opks[]' });
1051
+ return;
1052
+ }
1053
+ const MAX_OPKS_PER_UPLOAD = 20;
1054
+ const MAX_OPKS_PER_IDENTITY = 100;
1055
+ const batch = opks.slice(0, MAX_OPKS_PER_UPLOAD).filter((o) => typeof o === 'string' && o.length > 0);
1056
+ const identityKey = directoryKey(namespace, publicKey);
1057
+ const existing = stmts.countOpksForKey.get(identityKey).cnt;
1058
+ const canStore = Math.max(0, MAX_OPKS_PER_IDENTITY - existing);
1059
+ const toStore = batch.slice(0, canStore);
1060
+ const now = Date.now();
1061
+ for (const opk of toStore) {
1062
+ stmts.insertOpk.run(identityKey, opk, now);
1063
+ }
1064
+ sendJson(res, 200, { ok: true, stored: toStore.length });
1065
+ return;
1066
+ }
1067
+ // Purge all OPKs for a (namespace, publicKey) pair.
1068
+ // DELETE /opks { namespace, publicKey }
1069
+ // Authorization: Bearer <token>
1070
+ //
1071
+ // TOFU auth: first DELETE for a given publicKey establishes the auth hash;
1072
+ // subsequent DELETEs must present a token whose SHA-256 matches the stored
1073
+ // hash. Identity_pubkey is cross-namespace so the same person purging
1074
+ // their pool in different namespaces uses the same token.
1075
+ //
1076
+ // Used by the SDK's one-shot OPK-pool migration to clear zombie entries
1077
+ // left by the pre-fix bulk re-upload bug. Apps don't need to call this
1078
+ // directly — the SDK runs it on first init after the fix.
1079
+ if (url.pathname === '/opks' && method === 'DELETE') {
1080
+ if (rateLimited(req, res, 'dir', RATE_LIMIT_DIR))
1081
+ return;
1082
+ const auth = req.headers['authorization'];
1083
+ if (!auth || !auth.startsWith('Bearer ')) {
1084
+ sendJson(res, 401, { error: 'Missing bearer token' });
1085
+ return;
1086
+ }
1087
+ const token = auth.slice('Bearer '.length).trim();
1088
+ let body;
1089
+ try {
1090
+ body = JSON.parse(await parseBody(req));
1091
+ }
1092
+ catch {
1093
+ sendJson(res, 400, { error: 'Invalid JSON body' });
1094
+ return;
1095
+ }
1096
+ const { namespace, publicKey } = body;
1097
+ if (typeof namespace !== 'string' || !namespace || typeof publicKey !== 'string' || !publicKey) {
1098
+ sendJson(res, 400, { error: 'Missing required fields: namespace, publicKey' });
1099
+ return;
1100
+ }
1101
+ const incomingHash = nodeCrypto.createHash('sha256').update(token).digest('hex');
1102
+ // TOFU: insert-or-ignore establishes the hash on first call. Subsequent
1103
+ // calls succeed only if the hash matches what's already stored.
1104
+ stmts.insertOpkAuthHash.run(publicKey, incomingHash, Date.now());
1105
+ const existing = stmts.getOpkAuthHash.get(publicKey);
1106
+ if (!existing || existing.auth_hash !== incomingHash) {
1107
+ sendJson(res, 403, { error: 'Invalid OPK pool token' });
1108
+ return;
1109
+ }
1110
+ const result = stmts.deleteOpksForKey.run(directoryKey(namespace, publicKey));
1111
+ sendJson(res, 200, { ok: true, deleted: result.changes });
1112
+ return;
1113
+ }
1114
+ // Claim one one-time pre-key for a user (atomic — removes it from the pool)
1115
+ // GET /opks/claim?namespace=&publicKey=
1116
+ if (url.pathname === '/opks/claim' && method === 'GET') {
1117
+ // OPK claim is a write-like operation (each call atomically consumes a
1118
+ // one-time prekey from the pool). Bucket with the other write endpoints
1119
+ // so the directory budget gates abuse — an attacker draining a victim's
1120
+ // OPK pool to force fallback to the signed-prekey-only handshake is the
1121
+ // primary risk this addresses.
1122
+ if (rateLimited(req, res, 'dir', RATE_LIMIT_DIR))
1123
+ return;
424
1124
  const namespace = url.searchParams.get('namespace');
425
1125
  const publicKey = url.searchParams.get('publicKey');
426
1126
  if (!namespace || !publicKey) {
427
1127
  sendJson(res, 400, { error: 'Missing query params: namespace, publicKey' });
428
1128
  return;
429
1129
  }
430
- const bundle = lookupPrekey(namespace, publicKey);
431
- if (!bundle) {
432
- sendJson(res, 404, { error: 'Prekey bundle not found' });
1130
+ const opk = claimOpkTx(directoryKey(namespace, publicKey));
1131
+ if (!opk) {
1132
+ sendJson(res, 404, { error: 'No one-time pre-keys available' });
433
1133
  return;
434
1134
  }
435
- sendJson(res, 200, { bundle });
1135
+ sendJson(res, 200, { opk });
436
1136
  return;
437
1137
  }
438
1138
  // Upload encrypted media blob
439
1139
  // POST /media (binary body, Content-Length required)
440
1140
  // Returns: { id, url, expiresAt }
441
1141
  if (url.pathname === '/media' && method === 'POST') {
442
- if (!checkRateLimit(getClientIp(req), 'media', RATE_LIMIT_MEDIA)) {
443
- sendJson(res, 429, { error: 'Too many requests' });
1142
+ if (rateLimited(req, res, 'media', RATE_LIMIT_MEDIA))
444
1143
  return;
445
- }
446
1144
  const contentLength = parseInt(req.headers['content-length'] ?? '0', 10);
447
1145
  if (contentLength > MAX_MEDIA_SIZE) {
448
1146
  sendJson(res, 413, { error: `Payload too large (max ${MAX_MEDIA_SIZE} bytes)` });
@@ -479,6 +1177,8 @@ async function handleHttp(req, res) {
479
1177
  // GET /media/:id
480
1178
  const mediaMatch = url.pathname.match(/^\/media\/([0-9a-f]{32})$/);
481
1179
  if (mediaMatch && method === 'GET') {
1180
+ if (rateLimited(req, res, 'read', RATE_LIMIT_READ))
1181
+ return;
482
1182
  const data = fetchMedia(mediaMatch[1]);
483
1183
  if (!data) {
484
1184
  sendJson(res, 404, { error: 'Media not found or expired' });
@@ -493,6 +1193,78 @@ async function handleHttp(req, res) {
493
1193
  res.end(data);
494
1194
  return;
495
1195
  }
1196
+ // Upload encrypted archive
1197
+ // PUT /archive/:peerId Authorization: Bearer <token>
1198
+ const archivePutMatch = url.pathname.match(/^\/archive\/([0-9a-f]{64})$/);
1199
+ if (archivePutMatch && method === 'PUT') {
1200
+ if (rateLimited(req, res, 'archive', RATE_LIMIT_ARCHIVE))
1201
+ return;
1202
+ const peerId = archivePutMatch[1];
1203
+ const authHeader = req.headers['authorization'] ?? '';
1204
+ const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : '';
1205
+ if (!token) {
1206
+ sendJson(res, 401, { error: 'Missing Authorization header' });
1207
+ return;
1208
+ }
1209
+ const contentLength = parseInt(req.headers['content-length'] ?? '0', 10);
1210
+ if (contentLength > MAX_ARCHIVE_SIZE) {
1211
+ sendJson(res, 413, { error: `Archive too large (max ${MAX_ARCHIVE_SIZE} bytes)` });
1212
+ return;
1213
+ }
1214
+ const chunks = [];
1215
+ let received = 0;
1216
+ let aborted = false;
1217
+ await new Promise((resolve, reject) => {
1218
+ req.on('data', (chunk) => {
1219
+ received += chunk.length;
1220
+ if (received > MAX_ARCHIVE_SIZE) {
1221
+ aborted = true;
1222
+ req.destroy();
1223
+ return;
1224
+ }
1225
+ chunks.push(chunk);
1226
+ });
1227
+ req.on('end', resolve);
1228
+ req.on('error', reject);
1229
+ });
1230
+ if (aborted) {
1231
+ sendJson(res, 413, { error: `Archive too large (max ${MAX_ARCHIVE_SIZE} bytes)` });
1232
+ return;
1233
+ }
1234
+ const data = Buffer.concat(chunks);
1235
+ const incomingHash = nodeCrypto.createHash('sha256').update(token).digest('hex');
1236
+ // First write: INSERT OR IGNORE sets the auth_hash. The upsert only
1237
+ // succeeds when auth_hash matches, preventing overwrites by other parties.
1238
+ stmts.insertArchiveFirstTime.run(peerId, incomingHash, data, Date.now(), data.length);
1239
+ const existing = stmts.getArchiveAuthHash.get(peerId);
1240
+ if (!existing || existing.auth_hash !== incomingHash) {
1241
+ sendJson(res, 403, { error: 'Invalid archive token' });
1242
+ return;
1243
+ }
1244
+ stmts.upsertArchive.run(peerId, incomingHash, data, Date.now(), data.length);
1245
+ sendJson(res, 200, { ok: true, size: data.length });
1246
+ return;
1247
+ }
1248
+ // Download encrypted archive
1249
+ // GET /archive/:peerId (no auth — content is client-encrypted)
1250
+ const archiveGetMatch = url.pathname.match(/^\/archive\/([0-9a-f]{64})$/);
1251
+ if (archiveGetMatch && method === 'GET') {
1252
+ if (rateLimited(req, res, 'read', RATE_LIMIT_READ))
1253
+ return;
1254
+ const row = stmts.getArchiveData.get(archiveGetMatch[1]);
1255
+ if (!row) {
1256
+ sendJson(res, 404, { error: 'Archive not found' });
1257
+ return;
1258
+ }
1259
+ res.writeHead(200, {
1260
+ 'Content-Type': 'application/octet-stream',
1261
+ 'Content-Length': row.data.length,
1262
+ 'Access-Control-Allow-Origin': '*',
1263
+ 'Cache-Control': 'no-store',
1264
+ });
1265
+ res.end(row.data);
1266
+ return;
1267
+ }
496
1268
  sendJson(res, 404, { error: 'Not found' });
497
1269
  }
498
1270
  // ============================================================
@@ -504,11 +1276,89 @@ const httpServer = http.createServer((req, res) => {
504
1276
  res.writeHead(500).end();
505
1277
  });
506
1278
  });
1279
+ // ============================================================
1280
+ // Federation (docs/federation.md v1)
1281
+ //
1282
+ // Dormant unless a peers file with at least one entry exists. The
1283
+ // classifyLocal callback answers "can this packet be handled here?":
1284
+ // - a connected client holds the destHash → deliver ('delivered')
1285
+ // - a push registration exists for the destHash → store+wake ('stored';
1286
+ // the device is homed here, currently offline)
1287
+ // - neither → 'unknown' (forward onward)
1288
+ // ============================================================
1289
+ const FEDERATION_KEY_FILE = process.env.FEDERATION_KEY_FILE
1290
+ ?? path.join(path.dirname(DB_PATH), 'federation-key.json');
1291
+ const FEDERATION_PEERS_FILE = process.env.FEDERATION_PEERS_FILE
1292
+ ?? path.join(path.dirname(DB_PATH), 'federation-peers.json');
1293
+ const FEDERATION_BLOCKLIST_FILE = process.env.FEDERATION_BLOCKLIST_FILE
1294
+ ?? path.join(path.dirname(DB_PATH), 'federation-blocklist.json');
1295
+ // FEDERATION_MODE: 'off' | 'allowlist' | 'open'.
1296
+ // off — dormant regardless of peers file
1297
+ // allowlist — only pre-approved pubkeys; v1 behavior
1298
+ // open — accept any peer completing the handshake (recommended once
1299
+ // you're comfortable; per-peer rate limiting is the abuse
1300
+ // boundary). Peers file becomes the outbound bootstrap list.
1301
+ // Unset (back-compat): allowlist when the peers file has entries, else off.
1302
+ const federationPeersConfig = loadPeersConfig(FEDERATION_PEERS_FILE);
1303
+ const FEDERATION_MODE_RAW = (process.env.FEDERATION_MODE ?? '').toLowerCase();
1304
+ const federationMode = FEDERATION_MODE_RAW === 'open' ? 'open'
1305
+ : FEDERATION_MODE_RAW === 'allowlist' ? 'allowlist'
1306
+ : FEDERATION_MODE_RAW === 'off' ? 'off'
1307
+ : (federationPeersConfig.length > 0 ? 'allowlist' : 'off');
1308
+ const FEDERATION_MAX_PEERS = parseInt(process.env.FEDERATION_MAX_PEERS ?? '64', 10);
1309
+ const FEDERATION_RATE_LIMIT = parseInt(process.env.FEDERATION_RATE_LIMIT ?? '6000', 10); // frames/min/peer
1310
+ const federation = federationMode !== 'off'
1311
+ ? new FederationManager({
1312
+ key: loadOrCreateFederationKey(FEDERATION_KEY_FILE),
1313
+ peers: federationPeersConfig,
1314
+ mode: federationMode,
1315
+ blockedPubkeys: loadBlocklist(FEDERATION_BLOCKLIST_FILE),
1316
+ maxPeers: FEDERATION_MAX_PEERS,
1317
+ rateLimitPerMin: FEDERATION_RATE_LIMIT,
1318
+ classifyLocal: (packet) => {
1319
+ const destHash = readDestHash(packet);
1320
+ if (!destHash)
1321
+ return 'delivered'; // malformed — swallow, don't propagate
1322
+ const recipient = clientsByHash.get(destHash);
1323
+ if (recipient && recipient.readyState === WebSocket.OPEN) {
1324
+ // Same store-then-deliver race protection as the client path.
1325
+ const blobId = storeBlob(destHash, packet);
1326
+ recipient.send(packet, { binary: true }, (err) => {
1327
+ if (!err)
1328
+ stmts.deleteBlob.run(blobId);
1329
+ });
1330
+ bumpActivity('fwd');
1331
+ return 'delivered';
1332
+ }
1333
+ const pushReg = getPushRegistration(destHash);
1334
+ if (pushReg) {
1335
+ storeBlob(destHash, packet);
1336
+ notifyPush(destHash, pushReg);
1337
+ bumpActivity('wake');
1338
+ return 'stored';
1339
+ }
1340
+ return 'unknown';
1341
+ },
1342
+ })
1343
+ : null;
1344
+ if (federation) {
1345
+ federation.start();
1346
+ console.log(`[federation] mode=${federationMode}, ${federationPeersConfig.length} configured peer(s), max ${FEDERATION_MAX_PEERS}`);
1347
+ }
507
1348
  const wss = new WebSocketServer({ noServer: true });
508
1349
  wss.on('connection', (ws) => {
1350
+ metrics.websocketConnectionsTotal++;
509
1351
  handleWebSocketConnection(ws);
510
1352
  });
511
1353
  httpServer.on('upgrade', (req, socket, head) => {
1354
+ // Federation peers negotiate the meshwhisper-federation.v1 subprotocol;
1355
+ // everything else is a regular client-relay connection.
1356
+ const offered = (req.headers['sec-websocket-protocol'] ?? '')
1357
+ .split(',').map((s) => s.trim());
1358
+ if (federation && offered.includes(FEDERATION_SUBPROTOCOL)) {
1359
+ federation.handleUpgrade(req, socket, head);
1360
+ return;
1361
+ }
512
1362
  wss.handleUpgrade(req, socket, head, (ws) => {
513
1363
  wss.emit('connection', ws, req);
514
1364
  });
@@ -519,13 +1369,19 @@ const pruneInterval = setInterval(() => {
519
1369
  pruneExpiredMedia();
520
1370
  pruneRateLimitState();
521
1371
  }, PRUNE_INTERVAL_MS);
522
- pruneInterval.unref(); // don't keep process alive for pruning alone
523
- httpServer.listen(PORT, () => {
1372
+ pruneInterval.unref();
1373
+ // Default to '::' (IPv6 wildcard) which also accepts IPv4 on Linux (bindv6only=0).
1374
+ // Set LISTEN_HOST=0.0.0.0 to restrict to IPv4 only.
1375
+ const LISTEN_HOST = process.env.LISTEN_HOST ?? '::';
1376
+ httpServer.listen(PORT, LISTEN_HOST, () => {
524
1377
  console.log(`MeshWhisper Node listening on port ${PORT}`);
525
1378
  console.log(` Relay: ws://localhost:${PORT}`);
526
1379
  console.log(` Directory: http://localhost:${PORT}/directory`);
1380
+ console.log(` OPKs: http://localhost:${PORT}/opks`);
527
1381
  console.log(` Media: http://localhost:${PORT}/media`);
1382
+ console.log(` Archive: http://localhost:${PORT}/archive/:peerId`);
528
1383
  console.log(` Health: http://localhost:${PORT}/health`);
1384
+ console.log(` Database: ${DB_PATH}`);
529
1385
  console.log(` Blob TTL: ${BLOB_TTL_HOURS}h`);
530
1386
  console.log(` Media TTL: ${MEDIA_TTL_HOURS}h (max ${MAX_MEDIA_SIZE / (1024 * 1024)}MB per file)`);
531
1387
  console.log(` Base URL: ${BASE_URL || '(inferred from Host header — set BASE_URL in production)'}`);
@@ -539,8 +1395,14 @@ function shutdown() {
539
1395
  clearInterval(pruneInterval);
540
1396
  wss.clients.forEach((ws) => ws.close(1001, 'Node shutting down'));
541
1397
  wss.close();
542
- httpServer.close(() => process.exit(0));
543
- setTimeout(() => process.exit(1), 3000).unref();
1398
+ httpServer.close(() => {
1399
+ db.close();
1400
+ process.exit(0);
1401
+ });
1402
+ setTimeout(() => {
1403
+ db.close();
1404
+ process.exit(1);
1405
+ }, 3000).unref();
544
1406
  }
545
1407
  process.on('SIGINT', shutdown);
546
1408
  process.on('SIGTERM', shutdown);