@kraki/head 0.20.1 → 0.20.2
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/README.md +16 -0
- package/dist/cli.js +13 -0
- package/dist/cli.js.map +1 -1
- package/dist/metrics.d.ts +50 -0
- package/dist/metrics.js +117 -0
- package/dist/metrics.js.map +1 -0
- package/dist/observability.d.ts +40 -0
- package/dist/observability.js +58 -0
- package/dist/observability.js.map +1 -0
- package/dist/pulse-hub.d.ts +1 -0
- package/dist/pulse-hub.js +16 -2
- package/dist/pulse-hub.js.map +1 -1
- package/dist/push/apns.js +2 -1
- package/dist/push/apns.js.map +1 -1
- package/dist/push/index.d.ts +2 -0
- package/dist/push/index.js +16 -0
- package/dist/push/index.js.map +1 -1
- package/dist/server/account-control.d.ts +28 -0
- package/dist/server/account-control.js +288 -0
- package/dist/server/account-control.js.map +1 -0
- package/dist/server/auth-flow.d.ts +82 -0
- package/dist/server/auth-flow.js +315 -0
- package/dist/server/auth-flow.js.map +1 -0
- package/dist/server/presence.d.ts +8 -0
- package/dist/server/presence.js +103 -0
- package/dist/server/presence.js.map +1 -0
- package/dist/server/shared.d.ts +106 -0
- package/dist/server/shared.js +49 -0
- package/dist/server/shared.js.map +1 -0
- package/dist/server/voice-lease.d.ts +5 -0
- package/dist/server/voice-lease.js +111 -0
- package/dist/server/voice-lease.js.map +1 -0
- package/dist/server.d.ts +19 -131
- package/dist/server.js +65 -862
- package/dist/server.js.map +1 -1
- package/dist/storage.d.ts +4 -1
- package/dist/storage.js +5 -2
- package/dist/storage.js.map +1 -1
- package/package.json +3 -3
package/dist/server.js
CHANGED
|
@@ -1,59 +1,16 @@
|
|
|
1
|
-
import { RemoteAuthBackend } from './remote-auth-backend.js';
|
|
2
1
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { PulseHub
|
|
2
|
+
import { metrics } from './metrics.js';
|
|
3
|
+
import { HEAD_PULSE_TARGET } from '@kraki/protocol';
|
|
4
|
+
import { PulseHub } from './pulse-hub.js';
|
|
6
5
|
import { LocalAuthBackend } from './local-auth-backend.js';
|
|
7
6
|
import { getLogger } from './logger.js';
|
|
8
7
|
import { trace, fp } from './trace.js';
|
|
9
8
|
import { clientIp } from './client-ip.js';
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
return prefs;
|
|
16
|
-
const { voiceVocabulary: _omitted, ...rest } = prefs;
|
|
17
|
-
return rest;
|
|
18
|
-
}
|
|
19
|
-
const DEFAULT_VOICE_LEASE_TTL_SEC = 86_400;
|
|
20
|
-
const DEFAULT_VOICE_DAILY_QUOTA_SEC = 7_200;
|
|
21
|
-
const MIN_VOICE_LEASE_GRANT_SEC = 1;
|
|
22
|
-
const VALID_VOICE_RESOURCES = new Set(['voice/doubao']);
|
|
23
|
-
const DEFAULT_MAX_PAYLOAD = 10 * 1024 * 1024;
|
|
24
|
-
const DEFAULT_AUTH_TIMEOUT_MS = 15_000;
|
|
25
|
-
const DEFAULT_LOGIN_SCREEN_AUTH_TIMEOUT_MS = 10 * 60_000;
|
|
26
|
-
const MAX_MULTICAST_TARGETS = 64;
|
|
27
|
-
/** Send-buffer ceiling per socket before a stalled client is disconnected. */
|
|
28
|
-
const MAX_WS_BUFFERED_BYTES = 32 * 1024 * 1024;
|
|
29
|
-
/** How often the liveness sweep runs (ms). Faster than the 30s ping timer so
|
|
30
|
-
* device_pending detection latency tracks the recipient's ping cadence
|
|
31
|
-
* (~10s), not the slower liveness check cadence. */
|
|
32
|
-
const LIVENESS_SWEEP_INTERVAL_MS = 2_500;
|
|
33
|
-
/** Grace period after ping before broadcasting device_pending (ms).
|
|
34
|
-
* Overridable via PONG_GRACE_MS environment variable. */
|
|
35
|
-
const PONG_GRACE_MS = Math.max(1_000, parseInt(process.env.PONG_GRACE_MS ?? '8000', 10) || 8_000);
|
|
36
|
-
function isValidMessage(msg) {
|
|
37
|
-
if (typeof msg !== 'object' || msg === null)
|
|
38
|
-
return false;
|
|
39
|
-
const obj = msg;
|
|
40
|
-
return typeof obj.type === 'string' && obj.type.length > 0;
|
|
41
|
-
}
|
|
42
|
-
function isValidAuth(msg) {
|
|
43
|
-
if (!msg.device || typeof msg.device !== 'object')
|
|
44
|
-
return false;
|
|
45
|
-
const dev = msg.device;
|
|
46
|
-
if (typeof dev.name !== 'string' || typeof dev.role !== 'string')
|
|
47
|
-
return false;
|
|
48
|
-
if (dev.role !== 'tentacle' && dev.role !== 'app')
|
|
49
|
-
return false;
|
|
50
|
-
if (!msg.auth || typeof msg.auth !== 'object')
|
|
51
|
-
return false;
|
|
52
|
-
const auth = msg.auth;
|
|
53
|
-
if (typeof auth.method !== 'string')
|
|
54
|
-
return false;
|
|
55
|
-
return true;
|
|
56
|
-
}
|
|
9
|
+
import { handleControlMessage, firePushPreview } from './server/account-control.js';
|
|
10
|
+
import { handleAuth, handleChallengeResponse, handleCreatePairingToken, handleRequestPairingToken } from './server/auth-flow.js';
|
|
11
|
+
import { broadcastDeviceJoined, broadcastDeviceLeft, broadcastDevicePending } from './server/presence.js';
|
|
12
|
+
import { handleRequestVoiceLease } from './server/voice-lease.js';
|
|
13
|
+
import { DEFAULT_MAX_PAYLOAD, DEFAULT_AUTH_TIMEOUT_MS, DEFAULT_LOGIN_SCREEN_AUTH_TIMEOUT_MS, MAX_MULTICAST_TARGETS, MAX_WS_BUFFERED_BYTES, LIVENESS_SWEEP_INTERVAL_MS, PONG_GRACE_MS, isValidMessage, isValidAuth } from './server/shared.js';
|
|
57
14
|
export class HeadServer {
|
|
58
15
|
wss;
|
|
59
16
|
storage;
|
|
@@ -123,6 +80,7 @@ export class HeadServer {
|
|
|
123
80
|
if (state && !state.isAlive) {
|
|
124
81
|
// Missed last pong — connection is dead. Log diagnostic timing so
|
|
125
82
|
// post-mortems can tell network drop from event-loop block.
|
|
83
|
+
metrics().inc('conn.pongTimeout');
|
|
126
84
|
getLogger().info('Terminating stale connection (no pong)', {
|
|
127
85
|
deviceId,
|
|
128
86
|
msSincePingSent: state.lastPingSentAt ? now - state.lastPingSentAt : null,
|
|
@@ -171,6 +129,9 @@ export class HeadServer {
|
|
|
171
129
|
durableRows = this.storage.rawDb.prepare('SELECT COUNT(*) AS n FROM pulse_outbox').get().n;
|
|
172
130
|
}
|
|
173
131
|
catch { /* table may not exist yet */ }
|
|
132
|
+
const metricsNow = metrics();
|
|
133
|
+
metricsNow.lastHealthAt = Date.now();
|
|
134
|
+
const loop = metricsNow.takeLoopDelay();
|
|
174
135
|
getLogger().info('health', {
|
|
175
136
|
conns: this.connections.size,
|
|
176
137
|
endpoints: this.pulseHub.endpointCount(),
|
|
@@ -178,7 +139,8 @@ export class HeadServer {
|
|
|
178
139
|
rssMb: Math.round(mem.rss / 1048576),
|
|
179
140
|
heapUsedMb: Math.round(mem.heapUsed / 1048576),
|
|
180
141
|
heapTotalMb: Math.round(mem.heapTotal / 1048576),
|
|
181
|
-
evlLagMs:
|
|
142
|
+
evlLagMs: loop?.p99Ms,
|
|
143
|
+
evlMaxMs: loop?.maxMs,
|
|
182
144
|
});
|
|
183
145
|
}, HeadServer.PING_INTERVAL);
|
|
184
146
|
}
|
|
@@ -209,7 +171,7 @@ export class HeadServer {
|
|
|
209
171
|
continue;
|
|
210
172
|
state.pendingLivenessBroadcast = true;
|
|
211
173
|
getLogger().debug('Broadcasting device_pending (pong overdue)', { deviceId });
|
|
212
|
-
|
|
174
|
+
broadcastDevicePending(this, state.userId, deviceId);
|
|
213
175
|
}
|
|
214
176
|
}
|
|
215
177
|
/** A missing pong alone does not prove a dead link: when the downlink is
|
|
@@ -308,7 +270,9 @@ export class HeadServer {
|
|
|
308
270
|
this.sendError(ws, 'Invalid JSON');
|
|
309
271
|
}
|
|
310
272
|
});
|
|
273
|
+
metrics().inc('conn.opened');
|
|
311
274
|
ws.on('close', (code, reason) => {
|
|
275
|
+
metrics().inc('conn.closed');
|
|
312
276
|
if (state.authTimer)
|
|
313
277
|
clearTimeout(state.authTimer);
|
|
314
278
|
const reasonStr = reason?.toString?.() || '';
|
|
@@ -332,7 +296,7 @@ export class HeadServer {
|
|
|
332
296
|
closeReason: reasonStr,
|
|
333
297
|
});
|
|
334
298
|
if (disconnectedUserId) {
|
|
335
|
-
|
|
299
|
+
broadcastDeviceLeft(this, disconnectedUserId, disconnectedDeviceId);
|
|
336
300
|
}
|
|
337
301
|
}
|
|
338
302
|
else {
|
|
@@ -361,7 +325,7 @@ export class HeadServer {
|
|
|
361
325
|
async onMessage(ws, state, msg) {
|
|
362
326
|
// One-shot pairing token request — before auth
|
|
363
327
|
if (msg.type === 'request_pairing_token') {
|
|
364
|
-
await
|
|
328
|
+
await handleRequestPairingToken(this, ws, state, msg);
|
|
365
329
|
return;
|
|
366
330
|
}
|
|
367
331
|
// Pre-auth: auth_info, auth, auth_response
|
|
@@ -382,10 +346,10 @@ export class HeadServer {
|
|
|
382
346
|
this.sendError(ws, 'Invalid auth message: device.name and device.role required');
|
|
383
347
|
return;
|
|
384
348
|
}
|
|
385
|
-
await
|
|
349
|
+
await handleAuth(this, ws, state, msg);
|
|
386
350
|
}
|
|
387
351
|
else if (msg.type === 'auth_response' && state.pendingNonce) {
|
|
388
|
-
|
|
352
|
+
handleChallengeResponse(this, ws, state, msg);
|
|
389
353
|
}
|
|
390
354
|
else {
|
|
391
355
|
this.sendError(ws, 'Must authenticate first');
|
|
@@ -394,7 +358,7 @@ export class HeadServer {
|
|
|
394
358
|
}
|
|
395
359
|
// Authenticated messages
|
|
396
360
|
if (msg.type === 'create_pairing_token') {
|
|
397
|
-
await
|
|
361
|
+
await handleCreatePairingToken(this, ws, state);
|
|
398
362
|
return;
|
|
399
363
|
}
|
|
400
364
|
if (msg.type === 'unicast') {
|
|
@@ -425,6 +389,11 @@ export class HeadServer {
|
|
|
425
389
|
const sender = this.storage.getDevice(state.deviceId);
|
|
426
390
|
if (!target || target.userId !== state.userId || !sender || target.role === sender.role) {
|
|
427
391
|
this.sendError(ws, 'unicast target is not reachable');
|
|
392
|
+
// Still consume the frame on the sender's stream, forwarding it
|
|
393
|
+
// nowhere: an unconsumed seq would never be ACKed, the sender would
|
|
394
|
+
// retransmit it (with the same target) forever, and every later
|
|
395
|
+
// seq on that stream would wait behind it.
|
|
396
|
+
this.pulseHub.onPulseEnvelope(state.deviceId, { pulse: msg.pulse, to: [] });
|
|
428
397
|
return;
|
|
429
398
|
}
|
|
430
399
|
}
|
|
@@ -460,18 +429,20 @@ export class HeadServer {
|
|
|
460
429
|
this.sendError(ws, `multicast target count must be between 1 and ${MAX_MULTICAST_TARGETS}`);
|
|
461
430
|
return;
|
|
462
431
|
}
|
|
463
|
-
|
|
432
|
+
const allowed = requested.filter((targetId) => {
|
|
464
433
|
const target = this.storage.getDevice(targetId);
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
434
|
+
return targetId !== state.deviceId
|
|
435
|
+
&& targetId !== HEAD_PULSE_TARGET
|
|
436
|
+
&& !!target
|
|
437
|
+
&& target.userId === state.userId
|
|
438
|
+
&& target.role === 'app';
|
|
439
|
+
});
|
|
440
|
+
// Forbidden targets are dropped, not the frame: the frame must still be
|
|
441
|
+
// consumed on the sender's stream (see the unicast branch), and the
|
|
442
|
+
// allowed targets still get it.
|
|
443
|
+
if (allowed.length < requested.length)
|
|
444
|
+
this.sendError(ws, 'multicast contains a forbidden target');
|
|
445
|
+
const onlineTargets = allowed.filter((targetId) => this.connections.has(targetId));
|
|
475
446
|
this.pulseHub.onPulseEnvelope(state.deviceId, {
|
|
476
447
|
pulse: msg.pulse,
|
|
477
448
|
to: onlineTargets,
|
|
@@ -486,7 +457,7 @@ export class HeadServer {
|
|
|
486
457
|
// devices (the reliable message itself is delivered by the hub).
|
|
487
458
|
// Push previews are tentacle-originated (same rule as dispatch_push).
|
|
488
459
|
if (msg.pushPreview && this.storage.getDevice(state.deviceId)?.role === 'tentacle') {
|
|
489
|
-
|
|
460
|
+
firePushPreview(this, state, msg.pushPreview);
|
|
490
461
|
}
|
|
491
462
|
this.pulseHub.onPulseEnvelope(state.deviceId, { pulse: msg.pulse });
|
|
492
463
|
return;
|
|
@@ -496,10 +467,10 @@ export class HeadServer {
|
|
|
496
467
|
}
|
|
497
468
|
// Relay-terminated control ops (update_preferences / remove_device /
|
|
498
469
|
// (un)register_push_token). Arrive via pulse deliver-to-self (deliverPulseToSelf).
|
|
499
|
-
if (
|
|
470
|
+
if (handleControlMessage(this, ws, state, msg))
|
|
500
471
|
return;
|
|
501
472
|
if (msg.type === 'request_voice_lease') {
|
|
502
|
-
|
|
473
|
+
handleRequestVoiceLease(this, ws, state, msg);
|
|
503
474
|
return;
|
|
504
475
|
}
|
|
505
476
|
if (msg.type === 'ping') {
|
|
@@ -513,113 +484,6 @@ export class HeadServer {
|
|
|
513
484
|
this.sendError(ws, `Unknown message type: ${msg.type}`);
|
|
514
485
|
}
|
|
515
486
|
// --- Voice lease issuance ---
|
|
516
|
-
handleRequestVoiceLease(ws, state, msg) {
|
|
517
|
-
const logger = getLogger();
|
|
518
|
-
const userId = state.userId;
|
|
519
|
-
const deviceId = state.deviceId;
|
|
520
|
-
if (!userId || !deviceId) {
|
|
521
|
-
ws.send(JSON.stringify({
|
|
522
|
-
type: 'voice_lease_denied',
|
|
523
|
-
reason: 'invalid_request',
|
|
524
|
-
detail: 'Not authenticated',
|
|
525
|
-
}));
|
|
526
|
-
return;
|
|
527
|
-
}
|
|
528
|
-
const issuer = this.options.leaseIssuer;
|
|
529
|
-
if (!issuer) {
|
|
530
|
-
this.sendControlToDevice(deviceId, {
|
|
531
|
-
type: 'voice_lease_denied',
|
|
532
|
-
reason: 'not_entitled',
|
|
533
|
-
detail: 'Voice lease issuance is not enabled on this head',
|
|
534
|
-
});
|
|
535
|
-
return;
|
|
536
|
-
}
|
|
537
|
-
// Validate request shape.
|
|
538
|
-
const requestedDeviceId = typeof msg.deviceId === 'string' ? msg.deviceId : undefined;
|
|
539
|
-
const requestedResource = typeof msg.resource === 'string' ? msg.resource : undefined;
|
|
540
|
-
if (!requestedDeviceId || !requestedResource) {
|
|
541
|
-
this.sendControlToDevice(deviceId, {
|
|
542
|
-
type: 'voice_lease_denied',
|
|
543
|
-
reason: 'invalid_request',
|
|
544
|
-
detail: 'Missing deviceId or resource',
|
|
545
|
-
});
|
|
546
|
-
return;
|
|
547
|
-
}
|
|
548
|
-
// Lease may only be requested for the authenticated device — no proxying.
|
|
549
|
-
if (requestedDeviceId !== deviceId) {
|
|
550
|
-
this.sendControlToDevice(deviceId, {
|
|
551
|
-
type: 'voice_lease_denied',
|
|
552
|
-
reason: 'invalid_request',
|
|
553
|
-
detail: 'deviceId does not match authenticated device',
|
|
554
|
-
});
|
|
555
|
-
return;
|
|
556
|
-
}
|
|
557
|
-
if (!VALID_VOICE_RESOURCES.has(requestedResource)) {
|
|
558
|
-
this.sendControlToDevice(deviceId, {
|
|
559
|
-
type: 'voice_lease_denied',
|
|
560
|
-
reason: 'invalid_request',
|
|
561
|
-
detail: `Unknown resource: ${String(requestedResource)}`,
|
|
562
|
-
});
|
|
563
|
-
return;
|
|
564
|
-
}
|
|
565
|
-
// A lease is a long-lived credential for this device; the audio budget
|
|
566
|
-
// is granted incrementally by usage reports, charged to the UTC day the
|
|
567
|
-
// audio is used. So issuance only checks that anything is left today.
|
|
568
|
-
const nowSec = Math.floor(Date.now() / 1000);
|
|
569
|
-
const dailyCap = this.options.voiceDailyQuotaSec ?? DEFAULT_VOICE_DAILY_QUOTA_SEC;
|
|
570
|
-
this.storage.revokePendingVoiceLeases(userId, deviceId, requestedResource, nowSec);
|
|
571
|
-
const accountedToday = this.storage.voiceSecondsAccountedToday(userId, nowSec);
|
|
572
|
-
if (dailyCap - accountedToday < MIN_VOICE_LEASE_GRANT_SEC) {
|
|
573
|
-
logger.info('Voice lease denied: daily quota exhausted', {
|
|
574
|
-
userId, deviceId, accountedToday, dailyCap,
|
|
575
|
-
});
|
|
576
|
-
this.sendControlToDevice(deviceId, {
|
|
577
|
-
type: 'voice_lease_denied',
|
|
578
|
-
reason: 'quota_exhausted',
|
|
579
|
-
detail: `Daily quota reached (${accountedToday}/${dailyCap}s)`,
|
|
580
|
-
});
|
|
581
|
-
return;
|
|
582
|
-
}
|
|
583
|
-
const ttl = this.options.voiceLeaseTtlSec ?? DEFAULT_VOICE_LEASE_TTL_SEC;
|
|
584
|
-
// Signed ceiling for one lease: all the UTC days it can span. The real
|
|
585
|
-
// limit is the daily grant Head hands out on each usage report.
|
|
586
|
-
const grant = dailyCap * (Math.ceil(ttl / 86_400) + 1);
|
|
587
|
-
const lease = issuer.issue({
|
|
588
|
-
userId,
|
|
589
|
-
deviceId,
|
|
590
|
-
quotaSeconds: grant,
|
|
591
|
-
ttlSeconds: ttl,
|
|
592
|
-
resource: requestedResource,
|
|
593
|
-
nowUnixSec: nowSec,
|
|
594
|
-
});
|
|
595
|
-
try {
|
|
596
|
-
this.storage.recordVoiceLease({
|
|
597
|
-
jti: lease.payload.jti,
|
|
598
|
-
userId,
|
|
599
|
-
deviceId,
|
|
600
|
-
resource: requestedResource,
|
|
601
|
-
quotaSeconds: grant,
|
|
602
|
-
issuedAtUnixSec: lease.payload.iat,
|
|
603
|
-
expiresAtUnixSec: lease.payload.exp,
|
|
604
|
-
});
|
|
605
|
-
}
|
|
606
|
-
catch (err) {
|
|
607
|
-
logger.error('Voice lease persist failed', {
|
|
608
|
-
userId, deviceId, jti: lease.payload.jti, error: err.message,
|
|
609
|
-
});
|
|
610
|
-
this.sendControlToDevice(deviceId, {
|
|
611
|
-
type: 'voice_lease_denied',
|
|
612
|
-
reason: 'invalid_request',
|
|
613
|
-
detail: 'Lease persistence failed',
|
|
614
|
-
});
|
|
615
|
-
return;
|
|
616
|
-
}
|
|
617
|
-
logger.info('Voice lease issued', {
|
|
618
|
-
userId, deviceId, jti: lease.payload.jti,
|
|
619
|
-
quotaSeconds: grant, ttlSec: ttl, accountedToday, dailyCap,
|
|
620
|
-
});
|
|
621
|
-
this.sendControlToDevice(deviceId, { type: 'voice_lease_grant', lease });
|
|
622
|
-
}
|
|
623
487
|
// --- Routing ---
|
|
624
488
|
/** Send a pulse frame (control or forwarded data) to a device's socket, as a
|
|
625
489
|
* minimal unicast envelope carrying only the `pulse` field. Returns true if
|
|
@@ -634,6 +498,7 @@ export class HeadServer {
|
|
|
634
498
|
// socket's send buffer without bound. Drop the connection instead; every
|
|
635
499
|
// reliable frame is still in the pulse outbox and resends on reconnect.
|
|
636
500
|
if (ws.bufferedAmount > MAX_WS_BUFFERED_BYTES) {
|
|
501
|
+
metrics().inc('conn.stalledClosed');
|
|
637
502
|
getLogger().warn('Closing stalled connection (send buffer full)', { deviceId, bufferedBytes: ws.bufferedAmount });
|
|
638
503
|
ws.terminate();
|
|
639
504
|
return false;
|
|
@@ -641,6 +506,8 @@ export class HeadServer {
|
|
|
641
506
|
// `to` lets the receiver's own pulse layer know which stream this is; blob
|
|
642
507
|
// is empty because the payload (if any) is inside the pulse frame.
|
|
643
508
|
ws.send(JSON.stringify({ type: 'unicast', to: deviceId, pulse: pulseB64, blob: '', keys: {} }));
|
|
509
|
+
metrics().inc('pulse.frames.out');
|
|
510
|
+
metrics().inc('pulse.bytes.out', pulseB64.length);
|
|
644
511
|
return true;
|
|
645
512
|
}
|
|
646
513
|
/** Send a head-ORIGINATED control message to a device reliably over pulse
|
|
@@ -685,102 +552,7 @@ export class HeadServer {
|
|
|
685
552
|
}
|
|
686
553
|
// handleControlMessage whitelists the 4 relay-terminated types and returns
|
|
687
554
|
// false for anything else, so the self-channel cannot tunnel unicast/auth/etc.
|
|
688
|
-
|
|
689
|
-
}
|
|
690
|
-
/** The relay-terminated control ops, callable from BOTH the raw-WS dispatch
|
|
691
|
-
* (onMessage) and the pulse deliver-to-self path. Returns true if handled. */
|
|
692
|
-
handleControlMessage(ws, state, msg) {
|
|
693
|
-
switch (msg.type) {
|
|
694
|
-
case 'update_preferences':
|
|
695
|
-
this.handleUpdatePreferences(ws, state, msg);
|
|
696
|
-
return true;
|
|
697
|
-
case 'update_voice_vocabulary':
|
|
698
|
-
this.handleUpdateVoiceVocabulary(state, msg);
|
|
699
|
-
return true;
|
|
700
|
-
case 'remove_device':
|
|
701
|
-
this.handleRemoveDevice(ws, state, msg.deviceId);
|
|
702
|
-
return true;
|
|
703
|
-
case 'delete_account':
|
|
704
|
-
void this.handleDeleteAccount(ws, state);
|
|
705
|
-
return true;
|
|
706
|
-
case 'register_push_token':
|
|
707
|
-
this.handleRegisterPushToken(ws, state, msg);
|
|
708
|
-
return true;
|
|
709
|
-
case 'unregister_push_token':
|
|
710
|
-
this.handleUnregisterPushToken(ws, state, msg);
|
|
711
|
-
return true;
|
|
712
|
-
case 'dispatch_push': {
|
|
713
|
-
const sender = state.deviceId ? this.storage.getDevice(state.deviceId) : undefined;
|
|
714
|
-
const payload = msg.payload;
|
|
715
|
-
if (sender?.role !== 'tentacle' || !payload?.preview)
|
|
716
|
-
return true;
|
|
717
|
-
this.firePushPreview(state, payload.preview);
|
|
718
|
-
return true;
|
|
719
|
-
}
|
|
720
|
-
default: return false;
|
|
721
|
-
}
|
|
722
|
-
}
|
|
723
|
-
/** Persist a preferences update and echo the merged result to the sender + the
|
|
724
|
-
* user's other online devices. */
|
|
725
|
-
handleUpdatePreferences(ws, state, msg) {
|
|
726
|
-
if (!state.userId)
|
|
727
|
-
return;
|
|
728
|
-
const prefs = msg.preferences;
|
|
729
|
-
if (!prefs || typeof prefs !== 'object')
|
|
730
|
-
return;
|
|
731
|
-
this.storage.updatePreferences(state.userId, prefs);
|
|
732
|
-
// Read back the full merged preferences
|
|
733
|
-
const fullUser = this.storage.getUser(state.userId);
|
|
734
|
-
const merged = publicPreferences(fullUser?.preferences ?? prefs);
|
|
735
|
-
const confirmation = { type: 'preferences_updated', preferences: merged };
|
|
736
|
-
// Confirm to the sender + fan out to the user's other devices — all over pulse.
|
|
737
|
-
if (state.deviceId)
|
|
738
|
-
this.sendControlToDevice(state.deviceId, confirmation);
|
|
739
|
-
const userDevices = (() => {
|
|
740
|
-
try {
|
|
741
|
-
return this.storage.getDevicesByUser(state.userId);
|
|
742
|
-
}
|
|
743
|
-
catch {
|
|
744
|
-
return [];
|
|
745
|
-
}
|
|
746
|
-
})();
|
|
747
|
-
for (const d of userDevices) {
|
|
748
|
-
if (d.id === state.deviceId)
|
|
749
|
-
continue;
|
|
750
|
-
const other = this.connections.get(d.id);
|
|
751
|
-
if (!other || other.readyState !== WebSocket.OPEN)
|
|
752
|
-
continue;
|
|
753
|
-
this.sendControlToDevice(d.id, confirmation);
|
|
754
|
-
}
|
|
755
|
-
}
|
|
756
|
-
/** Account data, not session data: no tentacle is involved. The sender
|
|
757
|
-
* always gets the resulting list back (its acknowledgement); other online
|
|
758
|
-
* apps of the account get it only when something was applied. */
|
|
759
|
-
handleUpdateVoiceVocabulary(state, msg) {
|
|
760
|
-
if (!state.userId || !state.deviceId)
|
|
761
|
-
return;
|
|
762
|
-
const requestId = typeof msg.requestId === 'string' ? msg.requestId.slice(0, 64) : undefined;
|
|
763
|
-
const ops = parseVoiceWordOps(msg.ops);
|
|
764
|
-
let words;
|
|
765
|
-
try {
|
|
766
|
-
words = ops?.length
|
|
767
|
-
? this.storage.updateVoiceVocabulary(state.userId, ops)
|
|
768
|
-
: this.storage.getVoiceVocabulary(state.userId);
|
|
769
|
-
}
|
|
770
|
-
catch (err) {
|
|
771
|
-
// Pulse self-delivery has no outer try/catch. No reply: the client keeps
|
|
772
|
-
// the ops in its outbox and retries.
|
|
773
|
-
getLogger().warn('Voice vocabulary update failed', { error: err.message });
|
|
774
|
-
return;
|
|
775
|
-
}
|
|
776
|
-
this.sendControlToDevice(state.deviceId, { type: 'voice_vocabulary_updated', requestId, words });
|
|
777
|
-
if (!ops?.length)
|
|
778
|
-
return;
|
|
779
|
-
for (const d of this.storage.getDevicesByUser(state.userId)) {
|
|
780
|
-
if (d.id !== state.deviceId && d.role === 'app' && this.connections.get(d.id)?.readyState === WebSocket.OPEN) {
|
|
781
|
-
this.sendControlToDevice(d.id, { type: 'voice_vocabulary_updated', words });
|
|
782
|
-
}
|
|
783
|
-
}
|
|
555
|
+
handleControlMessage(this, ws, state, msg);
|
|
784
556
|
}
|
|
785
557
|
/** Fan-out targets for a pulse broadcast from `fromDevice`: all OTHER
|
|
786
558
|
* ONLINE devices of the same user. Offline devices are excluded because
|
|
@@ -810,46 +582,6 @@ export class HeadServer {
|
|
|
810
582
|
}
|
|
811
583
|
return targets;
|
|
812
584
|
}
|
|
813
|
-
/** Notify offline devices from a pushPreview side-channel. The preview rides the
|
|
814
|
-
* same pulse broadcast envelope as the reliable message. */
|
|
815
|
-
firePushPreview(state, pushPreview) {
|
|
816
|
-
const senderUserId = state.userId;
|
|
817
|
-
const senderDeviceId = state.deviceId;
|
|
818
|
-
if (!senderUserId || !senderDeviceId || !pushPreview) {
|
|
819
|
-
trace('PUSH-SKIP', { reason: !senderUserId ? 'no-user' : !senderDeviceId ? 'no-device' : 'no-preview' });
|
|
820
|
-
return;
|
|
821
|
-
}
|
|
822
|
-
// Online native clients that post their own notifications (macOS) get the
|
|
823
|
-
// same encrypted preview over their live connection.
|
|
824
|
-
for (const token of this.storage.getPushTokensForOfflineDevices(senderUserId, [])) {
|
|
825
|
-
if (token.provider !== 'local' || token.deviceId === senderDeviceId)
|
|
826
|
-
continue;
|
|
827
|
-
if (!this.connections.has(token.deviceId))
|
|
828
|
-
continue;
|
|
829
|
-
const key = pushPreview.keys[token.deviceId];
|
|
830
|
-
if (!key)
|
|
831
|
-
continue;
|
|
832
|
-
this.sendControlToDevice(token.deviceId, { type: 'notification_preview', payload: { blob: pushPreview.blob, key } });
|
|
833
|
-
trace('PUSH-LOCAL', { deviceId: token.deviceId });
|
|
834
|
-
}
|
|
835
|
-
if (!this.options.pushManager) {
|
|
836
|
-
trace('PUSH-SKIP', { reason: 'no-pushmgr' });
|
|
837
|
-
return;
|
|
838
|
-
}
|
|
839
|
-
const onlineDeviceIds = [senderDeviceId];
|
|
840
|
-
let offlineCount = 0;
|
|
841
|
-
for (const device of this.storage.getDevicesByUser(senderUserId)) {
|
|
842
|
-
if (device.id === senderDeviceId)
|
|
843
|
-
continue;
|
|
844
|
-
if (this.connections.get(device.id))
|
|
845
|
-
onlineDeviceIds.push(device.id);
|
|
846
|
-
else
|
|
847
|
-
offlineCount++;
|
|
848
|
-
}
|
|
849
|
-
trace('PUSH-FIRE', { sender: senderDeviceId, onlineCount: onlineDeviceIds.length, offlineCount, previewKeys: Object.keys(pushPreview.keys) });
|
|
850
|
-
this.options.pushManager.sendToOfflineDevices(senderUserId, onlineDeviceIds, pushPreview)
|
|
851
|
-
.catch(err => getLogger().warn('Push notification send failed', { error: err.message }));
|
|
852
|
-
}
|
|
853
585
|
removeConnection(deviceId) {
|
|
854
586
|
const ws = this.connections.get(deviceId);
|
|
855
587
|
const userId = this.userByDevice.get(deviceId);
|
|
@@ -882,547 +614,16 @@ export class HeadServer {
|
|
|
882
614
|
this.storage.touchDeviceLastSeen(deviceId);
|
|
883
615
|
}
|
|
884
616
|
catch { /* storage may be closed */ }
|
|
885
|
-
|
|
886
|
-
}
|
|
887
|
-
}
|
|
888
|
-
sendAuthError(ws, code, message, redirect, deviceId) {
|
|
889
|
-
if (ws.readyState === WebSocket.OPEN) {
|
|
890
|
-
ws.send(JSON.stringify({ type: 'auth_error', code, message, ...(redirect && { redirect }), ...(deviceId && { deviceId }) }));
|
|
617
|
+
broadcastDeviceLeft(this, userId, deviceId);
|
|
891
618
|
}
|
|
892
619
|
}
|
|
893
620
|
// --- Auth ---
|
|
894
|
-
async handleAuth(ws, state, msg) {
|
|
895
|
-
state.pulseProgressAck = msg.device?.pulseProgressAck === true;
|
|
896
|
-
const logger = getLogger();
|
|
897
|
-
const { auth } = msg;
|
|
898
|
-
// One auth attempt per socket at a time: a second `auth` while the first
|
|
899
|
-
// is awaiting its backend would register the device twice.
|
|
900
|
-
if (state.authInFlight || state.pendingNonce) {
|
|
901
|
-
// Ignored rather than answered with auth_error: clients treat auth
|
|
902
|
-
// errors as credential failures, and this one is just a duplicate.
|
|
903
|
-
logger.warn('Ignoring duplicate auth while one is in progress', { ip: state.ip });
|
|
904
|
-
return;
|
|
905
|
-
}
|
|
906
|
-
state.authInFlight = true;
|
|
907
|
-
try {
|
|
908
|
-
if (auth.method === 'challenge') {
|
|
909
|
-
// Challenge is multi-step — start here, verify in handleChallengeResponse
|
|
910
|
-
const result = await this.authBackend.startChallenge(auth.deviceId, msg.device.encryptionKey, this.options.region);
|
|
911
|
-
if (!this.socketStillAuthenticating(ws, state))
|
|
912
|
-
return;
|
|
913
|
-
if (!result.ok) {
|
|
914
|
-
this.sendAuthError(ws, result.code, result.message, result.code === 'wrong_region' ? result.redirect : undefined);
|
|
915
|
-
return;
|
|
916
|
-
}
|
|
917
|
-
state.pendingNonce = result.nonce;
|
|
918
|
-
state.pendingDeviceId = result.deviceId;
|
|
919
|
-
state.pendingDeviceInfo = { encryptionKey: msg.device.encryptionKey };
|
|
920
|
-
state.pendingAuthMethod = 'challenge';
|
|
921
|
-
logger.debug('Issuing auth challenge', { deviceId: auth.deviceId });
|
|
922
|
-
ws.send(JSON.stringify({ type: 'auth_challenge', nonce: result.nonce }));
|
|
923
|
-
return;
|
|
924
|
-
}
|
|
925
|
-
const result = await this.authBackend.authenticate(auth, msg.device, this.options.region, state.ip);
|
|
926
|
-
if (!this.socketStillAuthenticating(ws, state))
|
|
927
|
-
return;
|
|
928
|
-
this.handleBackendAuthResult(ws, state, result);
|
|
929
|
-
}
|
|
930
|
-
finally {
|
|
931
|
-
state.authInFlight = false;
|
|
932
|
-
}
|
|
933
|
-
}
|
|
934
|
-
/** After an awaited auth step: is this socket still open, still ours and
|
|
935
|
-
* still unauthenticated? The auth deadline may have closed it meanwhile;
|
|
936
|
-
* registering a dead socket would evict the device's live connection. */
|
|
937
|
-
socketStillAuthenticating(ws, state) {
|
|
938
|
-
return ws.readyState === WebSocket.OPEN && this.clients.get(ws) === state && !state.authenticated;
|
|
939
|
-
}
|
|
940
|
-
handleChallengeResponse(ws, state, msg) {
|
|
941
|
-
const logger = getLogger();
|
|
942
|
-
const deviceId = state.pendingDeviceId;
|
|
943
|
-
const nonce = state.pendingNonce;
|
|
944
|
-
const pendingInfo = state.pendingDeviceInfo;
|
|
945
|
-
state.pendingNonce = undefined;
|
|
946
|
-
state.pendingDeviceId = undefined;
|
|
947
|
-
state.pendingDeviceInfo = undefined;
|
|
948
|
-
if (!deviceId || !nonce) {
|
|
949
|
-
this.sendAuthError(ws, 'no_pending_challenge', 'No pending challenge');
|
|
950
|
-
return;
|
|
951
|
-
}
|
|
952
|
-
state.authInFlight = true;
|
|
953
|
-
this.authBackend.verifyChallenge(deviceId, nonce, msg.signature, pendingInfo?.encryptionKey, this.options.region).then(result => {
|
|
954
|
-
if (!this.socketStillAuthenticating(ws, state))
|
|
955
|
-
return;
|
|
956
|
-
this.handleBackendAuthResult(ws, state, result);
|
|
957
|
-
}).catch(err => {
|
|
958
|
-
// A thrown error means the auth backend itself was unreachable / timed
|
|
959
|
-
// out (network, 5xx, abort) — NOT that the signature was invalid. A
|
|
960
|
-
// deterministic auth failure comes back as a resolved AuthError outcome
|
|
961
|
-
// above. Reporting this as auth_rejected made the arm treat a transient
|
|
962
|
-
// backend outage as a permanent credential failure and wipe the paired
|
|
963
|
-
// device, forcing a full re-pair. Surface it as auth_unavailable so the
|
|
964
|
-
// client retries instead of destroying a valid session.
|
|
965
|
-
logger.error('Auth backend verifyChallenge failed', { error: err.message });
|
|
966
|
-
if (this.socketStillAuthenticating(ws, state)) {
|
|
967
|
-
this.sendAuthError(ws, 'service_unavailable', 'Authentication service unavailable');
|
|
968
|
-
}
|
|
969
|
-
}).finally(() => {
|
|
970
|
-
state.authInFlight = false;
|
|
971
|
-
});
|
|
972
|
-
}
|
|
973
|
-
/**
|
|
974
|
-
* Handle a result from AuthBackend — registers connection state and sends response.
|
|
975
|
-
* Used for both initial auth and challenge-response verification.
|
|
976
|
-
*/
|
|
977
|
-
handleBackendAuthResult(ws, state, result) {
|
|
978
|
-
const logger = getLogger();
|
|
979
|
-
if (!result.ok) {
|
|
980
|
-
const redirect = result.code === 'wrong_region' ? result.redirect : undefined;
|
|
981
|
-
const deviceId = result.code === 'wrong_region' ? result.deviceId : undefined;
|
|
982
|
-
this.sendAuthError(ws, result.code, result.message, redirect, deviceId);
|
|
983
|
-
return;
|
|
984
|
-
}
|
|
985
|
-
// Register connection
|
|
986
|
-
state.authenticated = true;
|
|
987
|
-
state.authenticatedAt = Date.now();
|
|
988
|
-
if (state.authTimer) {
|
|
989
|
-
clearTimeout(state.authTimer);
|
|
990
|
-
state.authTimer = undefined;
|
|
991
|
-
}
|
|
992
|
-
state.deviceId = result.deviceId;
|
|
993
|
-
state.userId = result.userId;
|
|
994
|
-
this.evictPreviousConnection(result.deviceId, ws);
|
|
995
|
-
this.connections.set(result.deviceId, ws);
|
|
996
|
-
this.userByDevice.set(result.deviceId, result.userId);
|
|
997
|
-
trace('DEV-CONNECT', { device: result.deviceId, user: result.userId, auth: 'backend' });
|
|
998
|
-
logger.info('Device authenticated (via backend)', {
|
|
999
|
-
deviceId: result.deviceId,
|
|
1000
|
-
user: result.user.login,
|
|
1001
|
-
method: result.authMethod,
|
|
1002
|
-
ip: state.ip,
|
|
1003
|
-
});
|
|
1004
|
-
// Mirror user + devices into local storage so edge-local operations
|
|
1005
|
-
// (broadcastDeviceJoined, getDeviceSummaries, etc.) work even when auth
|
|
1006
|
-
// is delegated to a remote backend.
|
|
1007
|
-
try {
|
|
1008
|
-
this.storage.upsertUser(result.user.id, result.user.login, result.user.provider, result.user.email);
|
|
1009
|
-
for (const d of result.devices) {
|
|
1010
|
-
try {
|
|
1011
|
-
this.storage.upsertDevice(d.id, result.userId, d.name, d.role, d.kind ?? undefined, d.publicKey ?? undefined, d.encryptionKey ?? undefined);
|
|
1012
|
-
}
|
|
1013
|
-
catch (err) {
|
|
1014
|
-
logger.warn('Failed to mirror device locally', { deviceId: d.id, error: err.message });
|
|
1015
|
-
}
|
|
1016
|
-
}
|
|
1017
|
-
}
|
|
1018
|
-
catch (err) {
|
|
1019
|
-
logger.warn('Failed to mirror user locally', { userId: result.userId, error: err.message });
|
|
1020
|
-
}
|
|
1021
|
-
this.completeAuthHandshake(ws, {
|
|
1022
|
-
deviceId: result.deviceId,
|
|
1023
|
-
userId: result.userId,
|
|
1024
|
-
user: result.user,
|
|
1025
|
-
authMethod: result.authMethod,
|
|
1026
|
-
devices: result.devices,
|
|
1027
|
-
githubClientId: result.githubClientId,
|
|
1028
|
-
vapidPublicKey: result.vapidPublicKey,
|
|
1029
|
-
});
|
|
1030
|
-
}
|
|
1031
|
-
/**
|
|
1032
|
-
* Common post-auth completion logic, used by every auth path.
|
|
1033
|
-
*
|
|
1034
|
-
* Responsibilities:
|
|
1035
|
-
* - Read user preferences from local storage and merge into the user object.
|
|
1036
|
-
* - Flush any locally-queued pending messages for this device (offline-queue).
|
|
1037
|
-
* - Build and send the `auth_ok` response.
|
|
1038
|
-
* - Broadcast `device_joined` to the user's other connected devices.
|
|
1039
|
-
*
|
|
1040
|
-
* Each caller is responsible for path-specific work BEFORE calling this:
|
|
1041
|
-
* validating credentials, persisting user/device records, setting up
|
|
1042
|
-
* ClientState, populating `connections` / `userByDevice` maps, and logging
|
|
1043
|
-
* the path-specific "authenticated via X" line.
|
|
1044
|
-
*
|
|
1045
|
-
* Centralising the tail eliminates a class of bugs where new auth paths
|
|
1046
|
-
* (notably the multi-region delegated-backend path) silently diverge from
|
|
1047
|
-
* the established post-auth flow.
|
|
1048
|
-
*/
|
|
1049
|
-
completeAuthHandshake(ws, params) {
|
|
1050
|
-
// Merge preferences. Local storage takes precedence (it captures per-edge
|
|
1051
|
-
// overrides the backend might not know about), but fall back to the
|
|
1052
|
-
// backend-supplied preferences if local has none — e.g. on a user's very
|
|
1053
|
-
// first auth to this edge, before upsertUser has been called or when the
|
|
1054
|
-
// backend is the canonical source of truth.
|
|
1055
|
-
//
|
|
1056
|
-
// Why this matters: storage.upsertUser only writes username/provider/email,
|
|
1057
|
-
// NOT preferences. So a brand-new user record reads back with
|
|
1058
|
-
// preferences=undefined. Without this fallback, backend preferences would
|
|
1059
|
-
// be silently dropped at every first auth.
|
|
1060
|
-
const fullUser = this.storage.getUser(params.userId);
|
|
1061
|
-
const userResponse = {
|
|
1062
|
-
...params.user,
|
|
1063
|
-
preferences: publicPreferences(fullUser?.preferences ?? params.user.preferences),
|
|
1064
|
-
};
|
|
1065
|
-
// Devices list. If the caller provided one (edge mode), recompute online
|
|
1066
|
-
// status against our current connections map. Otherwise derive locally.
|
|
1067
|
-
const devices = params.devices
|
|
1068
|
-
? params.devices.map(d => ({ ...d, online: this.connections.has(d.id) }))
|
|
1069
|
-
: this.getDeviceSummaries(params.userId);
|
|
1070
|
-
const deviceRole = devices.find((device) => device.id === params.deviceId)?.role
|
|
1071
|
-
?? this.storage.getDevice(params.deviceId)?.role;
|
|
1072
|
-
ws.send(JSON.stringify({
|
|
1073
|
-
type: 'auth_ok',
|
|
1074
|
-
deviceId: params.deviceId,
|
|
1075
|
-
authMethod: params.authMethod,
|
|
1076
|
-
user: userResponse,
|
|
1077
|
-
devices,
|
|
1078
|
-
githubClientId: params.githubClientId ?? this.authBackend.getAuthInfo().githubClientId,
|
|
1079
|
-
vapidPublicKey: params.vapidPublicKey ?? this.getVapidPublicKey(),
|
|
1080
|
-
relayVersion: this.options.version,
|
|
1081
|
-
pulseAckBytes: PULSE_ACK_EVERY_BYTES,
|
|
1082
|
-
// Apps only: a tentacle has no use for the user's word list.
|
|
1083
|
-
...(deviceRole === 'app' && { voiceVocabulary: this.storage.getVoiceVocabulary(params.userId) }),
|
|
1084
|
-
...(this.getVoiceCapability() && { voice: this.getVoiceCapability() }),
|
|
1085
|
-
}));
|
|
1086
|
-
// Clients only accept Pulse envelopes after processing auth_ok. Starting
|
|
1087
|
-
// the endpoint before auth_ok loses the initial hello, leaving peerEpoch
|
|
1088
|
-
// empty and making a later relay restart indistinguishable from the first
|
|
1089
|
-
// connection. That stale recvCursor then drops the restarted seq=1..N as
|
|
1090
|
-
// duplicates. Keep this ordering centralized for every auth path.
|
|
1091
|
-
//
|
|
1092
|
-
// Apps have explicit reconnect authorities (fresh session_list,
|
|
1093
|
-
// subscription snapshot, and history ranges). If their HELLO carries a new
|
|
1094
|
-
// process epoch, fence the preceding process's non-durable downlink so the
|
|
1095
|
-
// new UI cannot animate through old live events. Tentacle downlinks carry
|
|
1096
|
-
// commands and retain ordinary Pulse resume semantics across process epochs.
|
|
1097
|
-
this.pulseHub.onDeviceConnected(params.deviceId, {
|
|
1098
|
-
discardPreviousProcessNonDurable: deviceRole === 'app',
|
|
1099
|
-
});
|
|
1100
|
-
this.broadcastDeviceJoined(params.userId, params.deviceId);
|
|
1101
|
-
}
|
|
1102
|
-
/**
|
|
1103
|
-
* A device is about to register `ws` as its connection. If the SAME device id
|
|
1104
|
-
* already had a socket, that older socket is now orphaned: once we overwrite
|
|
1105
|
-
* the map entry its close handler hits the reconnect-guard and skips cleanup,
|
|
1106
|
-
* and the ping timer (which only walks `connections`) never visits it again.
|
|
1107
|
-
* It would sit in `clients` holding its ClientState until TCP finally gives
|
|
1108
|
-
* up. Retire both the WebSocket and its Pulse link explicitly before the new
|
|
1109
|
-
* connection is installed.
|
|
1110
|
-
*
|
|
1111
|
-
* Must be called BEFORE `connections.set(deviceId, ws)`.
|
|
1112
|
-
*/
|
|
1113
|
-
evictPreviousConnection(deviceId, ws) {
|
|
1114
|
-
const previous = this.connections.get(deviceId);
|
|
1115
|
-
if (!previous || previous === ws)
|
|
1116
|
-
return;
|
|
1117
|
-
getLogger().info('Replacing existing connection for device', { deviceId });
|
|
1118
|
-
// The old close callback will see the replacement in `connections` and skip
|
|
1119
|
-
// its guarded cleanup. Publish the Pulse disconnect here so capability and
|
|
1120
|
-
// connection-scoped state cannot leak across the replacement boundary.
|
|
1121
|
-
this.pulseHub.onDeviceDisconnected(deviceId);
|
|
1122
|
-
const previousState = this.clients.get(previous);
|
|
1123
|
-
if (previousState)
|
|
1124
|
-
previousState.evicted = true;
|
|
1125
|
-
this.clients.delete(previous);
|
|
1126
|
-
// Say why (a bare terminate() reads as a network drop, and a client that
|
|
1127
|
-
// reconnects on it evicts this new connection in turn). Force the TCP
|
|
1128
|
-
// teardown shortly after in case the old peer never completes the close.
|
|
1129
|
-
try {
|
|
1130
|
-
previous.close(DEVICE_REPLACED_CLOSE_CODE, 'replaced');
|
|
1131
|
-
}
|
|
1132
|
-
catch { /* best effort */ }
|
|
1133
|
-
const force = setTimeout(() => { try {
|
|
1134
|
-
previous.terminate();
|
|
1135
|
-
}
|
|
1136
|
-
catch { /* already gone */ } }, 2_000);
|
|
1137
|
-
force.unref?.();
|
|
1138
|
-
previous.once('close', () => clearTimeout(force));
|
|
1139
|
-
}
|
|
1140
621
|
// --- Pairing tokens (in-memory) ---
|
|
1141
|
-
async handleCreatePairingToken(ws, state) {
|
|
1142
|
-
const logger = getLogger();
|
|
1143
|
-
if (!(this.options.pairingEnabled ?? true)) {
|
|
1144
|
-
this.sendError(ws, 'Pairing is disabled on this relay');
|
|
1145
|
-
return;
|
|
1146
|
-
}
|
|
1147
|
-
if (!state.userId) {
|
|
1148
|
-
this.sendError(ws, 'Not authenticated');
|
|
1149
|
-
return;
|
|
1150
|
-
}
|
|
1151
|
-
try {
|
|
1152
|
-
// A remote (edge) backend creates tokens on the account service.
|
|
1153
|
-
const backend = this.authBackend;
|
|
1154
|
-
const result = backend.createPairingTokenAsync
|
|
1155
|
-
? await backend.createPairingTokenAsync(state.userId)
|
|
1156
|
-
: backend.createPairingToken(state.userId);
|
|
1157
|
-
if (!result?.token)
|
|
1158
|
-
throw new Error('no token returned');
|
|
1159
|
-
logger.info('Pairing token created', { userId: state.userId });
|
|
1160
|
-
if (ws.readyState === WebSocket.OPEN) {
|
|
1161
|
-
ws.send(JSON.stringify({ type: 'pairing_token_created', token: result.token, expiresIn: result.expiresIn }));
|
|
1162
|
-
}
|
|
1163
|
-
}
|
|
1164
|
-
catch (err) {
|
|
1165
|
-
logger.warn('Pairing token creation failed', { userId: state.userId, error: err.message });
|
|
1166
|
-
this.sendError(ws, 'Pairing is temporarily unavailable');
|
|
1167
|
-
}
|
|
1168
|
-
}
|
|
1169
|
-
async handleRequestPairingToken(ws, state, msg) {
|
|
1170
|
-
const logger = getLogger();
|
|
1171
|
-
if (!(this.options.pairingEnabled ?? true)) {
|
|
1172
|
-
this.sendError(ws, 'Pairing is disabled on this relay');
|
|
1173
|
-
return;
|
|
1174
|
-
}
|
|
1175
|
-
if (!msg.token) {
|
|
1176
|
-
this.sendError(ws, 'Token required for pairing request');
|
|
1177
|
-
return;
|
|
1178
|
-
}
|
|
1179
|
-
const result = await this.authBackend.requestPairingToken(msg.token, state.ip);
|
|
1180
|
-
if (ws.readyState !== WebSocket.OPEN)
|
|
1181
|
-
return;
|
|
1182
|
-
if (!result.ok) {
|
|
1183
|
-
this.sendAuthError(ws, result.code, result.message);
|
|
1184
|
-
return;
|
|
1185
|
-
}
|
|
1186
|
-
logger.info('Pairing token created (one-shot)', { userId: result.userId });
|
|
1187
|
-
ws.send(JSON.stringify({
|
|
1188
|
-
type: 'pairing_token_created',
|
|
1189
|
-
token: result.pairingToken,
|
|
1190
|
-
expiresIn: result.expiresIn,
|
|
1191
|
-
}));
|
|
1192
|
-
}
|
|
1193
622
|
// --- Device presence notifications ---
|
|
1194
|
-
handleRemoveDevice(ws, state, targetDeviceId) {
|
|
1195
|
-
const logger = getLogger();
|
|
1196
|
-
if (!state.userId || !state.deviceId) {
|
|
1197
|
-
this.sendError(ws, 'Not authenticated');
|
|
1198
|
-
return;
|
|
1199
|
-
}
|
|
1200
|
-
if (!targetDeviceId || typeof targetDeviceId !== 'string') {
|
|
1201
|
-
this.sendError(ws, 'Missing deviceId');
|
|
1202
|
-
return;
|
|
1203
|
-
}
|
|
1204
|
-
const targetDevice = this.storage.getDevice(targetDeviceId);
|
|
1205
|
-
if (!targetDevice || targetDevice.userId !== state.userId) {
|
|
1206
|
-
this.sendError(ws, 'Device not found');
|
|
1207
|
-
return;
|
|
1208
|
-
}
|
|
1209
|
-
// Only allow removing offline devices (zombie cleanup)
|
|
1210
|
-
if (this.connections.has(targetDeviceId)) {
|
|
1211
|
-
this.sendError(ws, 'Cannot remove an online device');
|
|
1212
|
-
return;
|
|
1213
|
-
}
|
|
1214
|
-
this.storage.deleteDevice(targetDeviceId);
|
|
1215
|
-
this.storage.deletePushTokensForDevice(targetDeviceId);
|
|
1216
|
-
// Drop the device's pulse endpoint + persisted outbox/snapshot/capability
|
|
1217
|
-
// rows too. Otherwise they outlive the device record forever, and
|
|
1218
|
-
// recoverOnBoot faithfully rebuilds an endpoint for a device that can
|
|
1219
|
-
// never authenticate again.
|
|
1220
|
-
this.pulseHub.forgetDevice(targetDeviceId);
|
|
1221
|
-
logger.info('Device removed', { deviceId: targetDeviceId, byDevice: state.deviceId });
|
|
1222
|
-
// Edge mode: the account service owns the device list and every auth
|
|
1223
|
-
// mirrors it back here, so the removal must reach it too (otherwise the
|
|
1224
|
-
// device reappears after the next reconnect).
|
|
1225
|
-
const remote = this.authBackend;
|
|
1226
|
-
if (remote instanceof RemoteAuthBackend) {
|
|
1227
|
-
const userId = state.userId;
|
|
1228
|
-
remote.removeDevice(userId, targetDeviceId).then((ok) => {
|
|
1229
|
-
if (!ok)
|
|
1230
|
-
logger.warn('Account service did not remove device', { deviceId: targetDeviceId });
|
|
1231
|
-
}).catch((err) => {
|
|
1232
|
-
logger.warn('Failed to remove device at account service', { deviceId: targetDeviceId, error: err.message });
|
|
1233
|
-
});
|
|
1234
|
-
}
|
|
1235
|
-
// Broadcast removal to all remaining user devices
|
|
1236
|
-
this.broadcastDeviceRemoved(state.userId, targetDeviceId);
|
|
1237
|
-
}
|
|
1238
623
|
// --- Account deletion ---
|
|
1239
624
|
/** Users whose deletion is in flight (a double tap must not run it twice). */
|
|
1240
625
|
deletingAccounts = new Set();
|
|
1241
|
-
/**
|
|
1242
|
-
* Delete the requesting user's account: at the account store (the account
|
|
1243
|
-
* service in edge mode) and in this relay's own storage and pulse state.
|
|
1244
|
-
* Then tell every connected device and close it. Devices that were offline
|
|
1245
|
-
* get `auth_error` code `account_deleted` when they next connect.
|
|
1246
|
-
*/
|
|
1247
|
-
async handleDeleteAccount(ws, state) {
|
|
1248
|
-
const logger = getLogger();
|
|
1249
|
-
const userId = state.userId;
|
|
1250
|
-
const deviceId = state.deviceId;
|
|
1251
|
-
if (!userId || !deviceId) {
|
|
1252
|
-
this.sendError(ws, 'Not authenticated');
|
|
1253
|
-
return;
|
|
1254
|
-
}
|
|
1255
|
-
if (this.storage.getDevice(deviceId)?.role !== 'app') {
|
|
1256
|
-
this.sendError(ws, 'Only the Kraki app can delete the account');
|
|
1257
|
-
return;
|
|
1258
|
-
}
|
|
1259
|
-
if (this.deletingAccounts.has(userId))
|
|
1260
|
-
return;
|
|
1261
|
-
this.deletingAccounts.add(userId);
|
|
1262
|
-
try {
|
|
1263
|
-
const deviceIds = new Set(this.storage.getDevicesByUser(userId).map((d) => d.id));
|
|
1264
|
-
if (this.authBackend.deleteAccount) {
|
|
1265
|
-
for (const id of await this.authBackend.deleteAccount(userId))
|
|
1266
|
-
deviceIds.add(id);
|
|
1267
|
-
}
|
|
1268
|
-
// This relay's own rows (the edge mirror; already gone when the
|
|
1269
|
-
// account store is this relay's storage).
|
|
1270
|
-
for (const id of this.storage.deleteUser(userId))
|
|
1271
|
-
deviceIds.add(id);
|
|
1272
|
-
logger.info('Account deleted', { userId, devices: deviceIds.size, byDevice: deviceId });
|
|
1273
|
-
for (const id of deviceIds) {
|
|
1274
|
-
const socket = this.connections.get(id);
|
|
1275
|
-
if (socket) {
|
|
1276
|
-
// Detach before closing so the close handler doesn't announce
|
|
1277
|
-
// device_left or touch the deleted rows.
|
|
1278
|
-
this.connections.delete(id);
|
|
1279
|
-
this.userByDevice.delete(id);
|
|
1280
|
-
this.clients.delete(socket);
|
|
1281
|
-
if (socket.readyState === WebSocket.OPEN) {
|
|
1282
|
-
socket.send(JSON.stringify({ type: 'account_deleted' }));
|
|
1283
|
-
socket.close(ACCOUNT_DELETED_CLOSE_CODE, 'account_deleted');
|
|
1284
|
-
}
|
|
1285
|
-
}
|
|
1286
|
-
this.pulseHub.forgetDevice(id);
|
|
1287
|
-
}
|
|
1288
|
-
}
|
|
1289
|
-
catch (err) {
|
|
1290
|
-
logger.error('Account deletion failed', { userId, error: err.message });
|
|
1291
|
-
this.sendError(ws, 'Could not delete the account. Try again.');
|
|
1292
|
-
}
|
|
1293
|
-
finally {
|
|
1294
|
-
this.deletingAccounts.delete(userId);
|
|
1295
|
-
}
|
|
1296
|
-
}
|
|
1297
626
|
// --- Push token management ---
|
|
1298
|
-
handleRegisterPushToken(ws, state, msg) {
|
|
1299
|
-
const logger = getLogger();
|
|
1300
|
-
if (!state.userId || !state.deviceId) {
|
|
1301
|
-
this.sendError(ws, 'Not authenticated');
|
|
1302
|
-
return;
|
|
1303
|
-
}
|
|
1304
|
-
const payload = msg.payload;
|
|
1305
|
-
if (!payload || typeof payload.token !== 'string' || typeof payload.provider !== 'string') {
|
|
1306
|
-
this.sendError(ws, 'Invalid push token: payload.token and payload.provider required');
|
|
1307
|
-
return;
|
|
1308
|
-
}
|
|
1309
|
-
this.storage.upsertPushToken(state.deviceId, payload.provider, payload.token, payload.environment, payload.bundleId);
|
|
1310
|
-
// Clean up push tokens from stale devices (offline >24h) to prevent
|
|
1311
|
-
// duplicate notifications from abandoned PWA installs on the same phone.
|
|
1312
|
-
// Only this user's devices matter; passing every connection on the relay
|
|
1313
|
-
// made each registration O(global connections) in SQL parameters.
|
|
1314
|
-
const onlineDeviceIds = this.storage.getDevicesByUser(state.userId)
|
|
1315
|
-
.map((d) => d.id)
|
|
1316
|
-
.filter((id) => this.connections.has(id));
|
|
1317
|
-
const pruned = this.storage.deleteStaleUserPushTokens(state.userId, state.deviceId, onlineDeviceIds);
|
|
1318
|
-
if (pruned > 0) {
|
|
1319
|
-
logger.info('Pruned stale push tokens', { userId: state.userId, count: pruned });
|
|
1320
|
-
}
|
|
1321
|
-
logger.info('Push token registered', { deviceId: state.deviceId, provider: payload.provider });
|
|
1322
|
-
if (state.deviceId) {
|
|
1323
|
-
this.sendControlToDevice(state.deviceId, { type: 'push_token_registered', payload: { provider: payload.provider } });
|
|
1324
|
-
}
|
|
1325
|
-
}
|
|
1326
|
-
handleUnregisterPushToken(ws, state, msg) {
|
|
1327
|
-
const logger = getLogger();
|
|
1328
|
-
if (!state.userId || !state.deviceId) {
|
|
1329
|
-
this.sendError(ws, 'Not authenticated');
|
|
1330
|
-
return;
|
|
1331
|
-
}
|
|
1332
|
-
const payload = msg.payload;
|
|
1333
|
-
if (!payload || typeof payload.provider !== 'string') {
|
|
1334
|
-
this.sendError(ws, 'Invalid unregister: payload.provider required');
|
|
1335
|
-
return;
|
|
1336
|
-
}
|
|
1337
|
-
this.storage.deletePushToken(state.deviceId, payload.provider);
|
|
1338
|
-
logger.info('Push token unregistered', { deviceId: state.deviceId, provider: payload.provider });
|
|
1339
|
-
}
|
|
1340
|
-
broadcastDeviceRemoved(userId, removedDeviceId) {
|
|
1341
|
-
let userDevices;
|
|
1342
|
-
try {
|
|
1343
|
-
userDevices = this.storage.getDevicesByUser(userId);
|
|
1344
|
-
}
|
|
1345
|
-
catch {
|
|
1346
|
-
return;
|
|
1347
|
-
}
|
|
1348
|
-
for (const d of userDevices) {
|
|
1349
|
-
const ws = this.connections.get(d.id);
|
|
1350
|
-
if (!ws || ws.readyState !== WebSocket.OPEN)
|
|
1351
|
-
continue;
|
|
1352
|
-
this.sendPresenceToDevice(d.id, { type: 'device_removed', deviceId: removedDeviceId });
|
|
1353
|
-
}
|
|
1354
|
-
}
|
|
1355
|
-
broadcastDeviceJoined(userId, newDeviceId) {
|
|
1356
|
-
let device;
|
|
1357
|
-
try {
|
|
1358
|
-
device = this.storage.getDevice(newDeviceId);
|
|
1359
|
-
}
|
|
1360
|
-
catch {
|
|
1361
|
-
return;
|
|
1362
|
-
} // Storage may be closed during shutdown
|
|
1363
|
-
if (!device)
|
|
1364
|
-
return;
|
|
1365
|
-
const summary = {
|
|
1366
|
-
id: device.id,
|
|
1367
|
-
name: device.name,
|
|
1368
|
-
role: device.role,
|
|
1369
|
-
kind: device.kind ?? undefined,
|
|
1370
|
-
publicKey: device.publicKey ?? undefined,
|
|
1371
|
-
encryptionKey: device.encryptionKey ?? undefined,
|
|
1372
|
-
online: true,
|
|
1373
|
-
lastSeen: device.lastSeen,
|
|
1374
|
-
createdAt: device.createdAt,
|
|
1375
|
-
};
|
|
1376
|
-
let userDevices;
|
|
1377
|
-
try {
|
|
1378
|
-
userDevices = this.storage.getDevicesByUser(userId);
|
|
1379
|
-
}
|
|
1380
|
-
catch {
|
|
1381
|
-
return;
|
|
1382
|
-
}
|
|
1383
|
-
for (const d of userDevices) {
|
|
1384
|
-
if (d.id === newDeviceId)
|
|
1385
|
-
continue;
|
|
1386
|
-
const ws = this.connections.get(d.id);
|
|
1387
|
-
if (!ws || ws.readyState !== WebSocket.OPEN)
|
|
1388
|
-
continue;
|
|
1389
|
-
this.sendPresenceToDevice(d.id, { type: 'device_joined', device: summary });
|
|
1390
|
-
}
|
|
1391
|
-
}
|
|
1392
|
-
broadcastDeviceLeft(userId, leftDeviceId) {
|
|
1393
|
-
let userDevices;
|
|
1394
|
-
try {
|
|
1395
|
-
userDevices = this.storage.getDevicesByUser(userId);
|
|
1396
|
-
}
|
|
1397
|
-
catch {
|
|
1398
|
-
return;
|
|
1399
|
-
} // Storage may be closed during shutdown
|
|
1400
|
-
for (const d of userDevices) {
|
|
1401
|
-
if (d.id === leftDeviceId)
|
|
1402
|
-
continue;
|
|
1403
|
-
const ws = this.connections.get(d.id);
|
|
1404
|
-
if (!ws || ws.readyState !== WebSocket.OPEN)
|
|
1405
|
-
continue;
|
|
1406
|
-
this.sendPresenceToDevice(d.id, { type: 'device_left', deviceId: leftDeviceId });
|
|
1407
|
-
}
|
|
1408
|
-
}
|
|
1409
|
-
broadcastDevicePending(userId, pendingDeviceId) {
|
|
1410
|
-
let userDevices;
|
|
1411
|
-
try {
|
|
1412
|
-
userDevices = this.storage.getDevicesByUser(userId);
|
|
1413
|
-
}
|
|
1414
|
-
catch {
|
|
1415
|
-
return;
|
|
1416
|
-
}
|
|
1417
|
-
for (const d of userDevices) {
|
|
1418
|
-
if (d.id === pendingDeviceId)
|
|
1419
|
-
continue;
|
|
1420
|
-
const ws = this.connections.get(d.id);
|
|
1421
|
-
if (!ws || ws.readyState !== WebSocket.OPEN)
|
|
1422
|
-
continue;
|
|
1423
|
-
this.sendPresenceToDevice(d.id, { type: 'device_pending', deviceId: pendingDeviceId });
|
|
1424
|
-
}
|
|
1425
|
-
}
|
|
1426
627
|
/** Handle pong from either protocol-level or JSON pong.
|
|
1427
628
|
* Re-promotes to device_joined if we already broadcast device_pending.
|
|
1428
629
|
* Guards against stale pongs from replaced connections (reconnect race). */
|
|
@@ -1444,30 +645,32 @@ export class HeadServer {
|
|
|
1444
645
|
const activeWs = this.connections.get(state.deviceId);
|
|
1445
646
|
const activeState = activeWs ? this.clients.get(activeWs) : null;
|
|
1446
647
|
if (activeState === state) {
|
|
1447
|
-
|
|
648
|
+
broadcastDeviceJoined(this, state.userId, state.deviceId);
|
|
1448
649
|
}
|
|
1449
650
|
}
|
|
1450
651
|
}
|
|
1451
652
|
// --- Helpers ---
|
|
1452
|
-
getDeviceSummaries(userId) {
|
|
1453
|
-
const stored = this.storage.getDevicesByUser(userId);
|
|
1454
|
-
return stored.map(d => ({
|
|
1455
|
-
id: d.id,
|
|
1456
|
-
name: d.name,
|
|
1457
|
-
role: d.role,
|
|
1458
|
-
kind: d.kind ?? undefined,
|
|
1459
|
-
publicKey: d.publicKey ?? undefined,
|
|
1460
|
-
encryptionKey: d.encryptionKey ?? undefined,
|
|
1461
|
-
online: this.connections.has(d.id),
|
|
1462
|
-
lastSeen: d.lastSeen,
|
|
1463
|
-
createdAt: d.createdAt,
|
|
1464
|
-
}));
|
|
1465
|
-
}
|
|
1466
653
|
sendError(ws, message) {
|
|
1467
654
|
if (ws.readyState === WebSocket.OPEN) {
|
|
1468
655
|
ws.send(JSON.stringify({ type: 'server_error', message }));
|
|
1469
656
|
}
|
|
1470
657
|
}
|
|
658
|
+
/** Live gauges for `/internal/metrics` (counts only). */
|
|
659
|
+
getGauges() {
|
|
660
|
+
let durableRows = -1;
|
|
661
|
+
try {
|
|
662
|
+
durableRows = this.storage.rawDb.prepare('SELECT COUNT(*) AS n FROM pulse_outbox').get().n;
|
|
663
|
+
}
|
|
664
|
+
catch { /* table may not exist yet */ }
|
|
665
|
+
const mem = process.memoryUsage();
|
|
666
|
+
return {
|
|
667
|
+
connections: this.connections.size,
|
|
668
|
+
endpoints: this.pulseHub.endpointCount(),
|
|
669
|
+
durableRows,
|
|
670
|
+
rssMb: Math.round(mem.rss / 1048576),
|
|
671
|
+
heapUsedMb: Math.round(mem.heapUsed / 1048576),
|
|
672
|
+
};
|
|
673
|
+
}
|
|
1471
674
|
getStats() {
|
|
1472
675
|
const onlineUserIds = new Set();
|
|
1473
676
|
const connectionList = [];
|