@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/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 { HEAD_PULSE_TARGET, ACCOUNT_DELETED_CLOSE_CODE, DEVICE_REPLACED_CLOSE_CODE } from '@kraki/protocol';
4
- import { parseVoiceWordOps } from './voice-vocabulary.js';
5
- import { PulseHub, PULSE_ACK_EVERY_BYTES } from './pulse-hub.js';
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
- /** Custom Words has its own channel (`auth_ok.voiceVocabulary` and
11
- * `voice_vocabulary_updated`). Never ship it inside generic preferences: that
12
- * would duplicate it on every auth and broadcast it on every theme change. */
13
- function publicPreferences(prefs) {
14
- if (!prefs || !('voiceVocabulary' in prefs))
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: undefined,
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
- this.broadcastDevicePending(state.userId, deviceId);
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
- this.broadcastDeviceLeft(disconnectedUserId, disconnectedDeviceId);
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 this.handleRequestPairingToken(ws, state, msg);
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 this.handleAuth(ws, state, msg);
349
+ await handleAuth(this, ws, state, msg);
386
350
  }
387
351
  else if (msg.type === 'auth_response' && state.pendingNonce) {
388
- this.handleChallengeResponse(ws, state, msg);
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 this.handleCreatePairingToken(ws, state);
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
- for (const targetId of requested) {
432
+ const allowed = requested.filter((targetId) => {
464
433
  const target = this.storage.getDevice(targetId);
465
- if (targetId === state.deviceId
466
- || targetId === HEAD_PULSE_TARGET
467
- || !target
468
- || target.userId !== state.userId
469
- || target.role !== 'app') {
470
- this.sendError(ws, 'multicast contains a forbidden target');
471
- return;
472
- }
473
- }
474
- const onlineTargets = requested.filter((targetId) => this.connections.has(targetId));
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
- this.firePushPreview(state, msg.pushPreview);
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 (this.handleControlMessage(ws, state, msg))
470
+ if (handleControlMessage(this, ws, state, msg))
500
471
  return;
501
472
  if (msg.type === 'request_voice_lease') {
502
- this.handleRequestVoiceLease(ws, state, msg);
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
- this.handleControlMessage(ws, state, msg);
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
- this.broadcastDeviceLeft(userId, deviceId);
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
- this.broadcastDeviceJoined(state.userId, state.deviceId);
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 = [];