@kraki/head 0.20.0 → 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.
Files changed (57) hide show
  1. package/README.md +16 -0
  2. package/dist/account-api.d.ts +10 -0
  3. package/dist/account-api.js +67 -15
  4. package/dist/account-api.js.map +1 -1
  5. package/dist/auth-backend.d.ts +16 -5
  6. package/dist/auth.d.ts +0 -1
  7. package/dist/auth.js +32 -20
  8. package/dist/auth.js.map +1 -1
  9. package/dist/cli.js +25 -1
  10. package/dist/cli.js.map +1 -1
  11. package/dist/lease-issuer.js +1 -1
  12. package/dist/lease-issuer.js.map +1 -1
  13. package/dist/local-auth-backend.d.ts +5 -1
  14. package/dist/local-auth-backend.js +30 -17
  15. package/dist/local-auth-backend.js.map +1 -1
  16. package/dist/metrics.d.ts +50 -0
  17. package/dist/metrics.js +117 -0
  18. package/dist/metrics.js.map +1 -0
  19. package/dist/observability.d.ts +40 -0
  20. package/dist/observability.js +58 -0
  21. package/dist/observability.js.map +1 -0
  22. package/dist/pulse-hub.d.ts +9 -0
  23. package/dist/pulse-hub.js +67 -5
  24. package/dist/pulse-hub.js.map +1 -1
  25. package/dist/push/apns.js +21 -2
  26. package/dist/push/apns.js.map +1 -1
  27. package/dist/push/index.d.ts +2 -0
  28. package/dist/push/index.js +16 -0
  29. package/dist/push/index.js.map +1 -1
  30. package/dist/remote-auth-backend.d.ts +2 -1
  31. package/dist/remote-auth-backend.js +10 -2
  32. package/dist/remote-auth-backend.js.map +1 -1
  33. package/dist/server/account-control.d.ts +28 -0
  34. package/dist/server/account-control.js +288 -0
  35. package/dist/server/account-control.js.map +1 -0
  36. package/dist/server/auth-flow.d.ts +82 -0
  37. package/dist/server/auth-flow.js +315 -0
  38. package/dist/server/auth-flow.js.map +1 -0
  39. package/dist/server/presence.d.ts +8 -0
  40. package/dist/server/presence.js +103 -0
  41. package/dist/server/presence.js.map +1 -0
  42. package/dist/server/shared.d.ts +106 -0
  43. package/dist/server/shared.js +49 -0
  44. package/dist/server/shared.js.map +1 -0
  45. package/dist/server/voice-lease.d.ts +5 -0
  46. package/dist/server/voice-lease.js +111 -0
  47. package/dist/server/voice-lease.js.map +1 -0
  48. package/dist/server.d.ts +20 -125
  49. package/dist/server.js +114 -1072
  50. package/dist/server.js.map +1 -1
  51. package/dist/storage.d.ts +21 -1
  52. package/dist/storage.js +88 -12
  53. package/dist/storage.js.map +1 -1
  54. package/dist/trace.d.ts +0 -1
  55. package/dist/trace.js +0 -3
  56. package/dist/trace.js.map +1 -1
  57. package/package.json +3 -4
package/dist/server.js CHANGED
@@ -1,68 +1,16 @@
1
- import { RemoteAuthBackend } from './remote-auth-backend.js';
2
1
  import { WebSocketServer, WebSocket } from 'ws';
3
- import { randomBytes, createVerify } from 'crypto';
4
- import { v4 as uuid } from 'uuid';
2
+ import { metrics } from './metrics.js';
5
3
  import { HEAD_PULSE_TARGET } from '@kraki/protocol';
6
- import { parseVoiceWordOps } from './voice-vocabulary.js';
7
- import { PulseHub, PULSE_ACK_EVERY_BYTES } from './pulse-hub.js';
8
- import { GitHubAuthProvider } from './auth.js';
4
+ import { PulseHub } from './pulse-hub.js';
5
+ import { LocalAuthBackend } from './local-auth-backend.js';
9
6
  import { getLogger } from './logger.js';
10
7
  import { trace, fp } from './trace.js';
11
8
  import { clientIp } from './client-ip.js';
12
- /** Custom Words has its own channel (`auth_ok.voiceVocabulary` and
13
- * `voice_vocabulary_updated`). Never ship it inside generic preferences: that
14
- * would duplicate it on every auth and broadcast it on every theme change. */
15
- function publicPreferences(prefs) {
16
- if (!prefs || !('voiceVocabulary' in prefs))
17
- return prefs;
18
- const { voiceVocabulary: _omitted, ...rest } = prefs;
19
- return rest;
20
- }
21
- function importPublicKey(compactKey) {
22
- const lines = compactKey.match(/.{1,64}/g) ?? [];
23
- return `-----BEGIN PUBLIC KEY-----\n${lines.join('\n')}\n-----END PUBLIC KEY-----\n`;
24
- }
25
- function verifySignature(nonce, signature, publicKeyPem) {
26
- const verify = createVerify('SHA256');
27
- verify.update(nonce);
28
- return verify.verify(publicKeyPem, signature, 'base64');
29
- }
30
- const DEFAULT_VOICE_LEASE_TTL_SEC = 86_400;
31
- const DEFAULT_VOICE_DAILY_QUOTA_SEC = 7_200;
32
- const MIN_VOICE_LEASE_GRANT_SEC = 1;
33
- const VALID_VOICE_RESOURCES = new Set(['voice/doubao']);
34
- const DEFAULT_MAX_PAYLOAD = 10 * 1024 * 1024;
35
- const DEFAULT_AUTH_TIMEOUT_MS = 15_000;
36
- const DEFAULT_LOGIN_SCREEN_AUTH_TIMEOUT_MS = 10 * 60_000;
37
- const MAX_MULTICAST_TARGETS = 64;
38
- /** How often the liveness sweep runs (ms). Faster than the 30s ping timer so
39
- * device_pending detection latency tracks the recipient's ping cadence
40
- * (~10s), not the slower liveness check cadence. */
41
- const LIVENESS_SWEEP_INTERVAL_MS = 2_500;
42
- /** Grace period after ping before broadcasting device_pending (ms).
43
- * Overridable via PONG_GRACE_MS environment variable. */
44
- const PONG_GRACE_MS = Math.max(1_000, parseInt(process.env.PONG_GRACE_MS ?? '8000', 10) || 8_000);
45
- function isValidMessage(msg) {
46
- if (typeof msg !== 'object' || msg === null)
47
- return false;
48
- const obj = msg;
49
- return typeof obj.type === 'string' && obj.type.length > 0;
50
- }
51
- function isValidAuth(msg) {
52
- if (!msg.device || typeof msg.device !== 'object')
53
- return false;
54
- const dev = msg.device;
55
- if (typeof dev.name !== 'string' || typeof dev.role !== 'string')
56
- return false;
57
- if (dev.role !== 'tentacle' && dev.role !== 'app')
58
- return false;
59
- if (!msg.auth || typeof msg.auth !== 'object')
60
- return false;
61
- const auth = msg.auth;
62
- if (typeof auth.method !== 'string')
63
- return false;
64
- return true;
65
- }
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';
66
14
  export class HeadServer {
67
15
  wss;
68
16
  storage;
@@ -70,7 +18,7 @@ export class HeadServer {
70
18
  static PING_INTERVAL = 30_000;
71
19
  // In-memory state
72
20
  connections = new Map();
73
- pairingTokens = new Map();
21
+ authBackend;
74
22
  userByDevice = new Map();
75
23
  clients = new Map();
76
24
  pingTimer = null;
@@ -81,6 +29,21 @@ export class HeadServer {
81
29
  constructor(storage, options) {
82
30
  this.storage = storage;
83
31
  this.options = options;
32
+ // One auth implementation for every deployment: without an explicit
33
+ // backend (standalone tests, self-hosted defaults) the server builds the
34
+ // same LocalAuthBackend production uses from its providers.
35
+ this.authBackend = options.authBackend ?? new LocalAuthBackend({
36
+ storage,
37
+ // A single `authProvider` answers every token method, as it always has.
38
+ authProviders: options.authProviders
39
+ ?? (options.authProvider
40
+ ? new Map(['open', 'apikey', 'github'].map((method) => [method, options.authProvider]))
41
+ : new Map()),
42
+ pairingEnabled: options.pairingEnabled,
43
+ pairingTtl: options.pairingTtl,
44
+ pushManager: options.pushManager,
45
+ region: options.region,
46
+ });
84
47
  this.wss = new WebSocketServer({
85
48
  noServer: true,
86
49
  maxPayload: options.maxPayload ?? DEFAULT_MAX_PAYLOAD,
@@ -117,6 +80,7 @@ export class HeadServer {
117
80
  if (state && !state.isAlive) {
118
81
  // Missed last pong — connection is dead. Log diagnostic timing so
119
82
  // post-mortems can tell network drop from event-loop block.
83
+ metrics().inc('conn.pongTimeout');
120
84
  getLogger().info('Terminating stale connection (no pong)', {
121
85
  deviceId,
122
86
  msSincePingSent: state.lastPingSentAt ? now - state.lastPingSentAt : null,
@@ -154,10 +118,7 @@ export class HeadServer {
154
118
  }
155
119
  }
156
120
  // Sweep expired pairing tokens
157
- for (const [token, data] of this.pairingTokens) {
158
- if (now > data.expiresAt)
159
- this.pairingTokens.delete(token);
160
- }
121
+ this.authBackend.sweepPairingTokens?.();
161
122
  // Periodic health snapshot — ALWAYS on (not trace-gated). Cheap (one line
162
123
  // / 30s) and is the canary for the OOM / event-loop-stall class of bugs
163
124
  // that recurred across the pulse PRs. rss/heap from process.memoryUsage;
@@ -168,6 +129,9 @@ export class HeadServer {
168
129
  durableRows = this.storage.rawDb.prepare('SELECT COUNT(*) AS n FROM pulse_outbox').get().n;
169
130
  }
170
131
  catch { /* table may not exist yet */ }
132
+ const metricsNow = metrics();
133
+ metricsNow.lastHealthAt = Date.now();
134
+ const loop = metricsNow.takeLoopDelay();
171
135
  getLogger().info('health', {
172
136
  conns: this.connections.size,
173
137
  endpoints: this.pulseHub.endpointCount(),
@@ -175,7 +139,8 @@ export class HeadServer {
175
139
  rssMb: Math.round(mem.rss / 1048576),
176
140
  heapUsedMb: Math.round(mem.heapUsed / 1048576),
177
141
  heapTotalMb: Math.round(mem.heapTotal / 1048576),
178
- evlLagMs: undefined,
142
+ evlLagMs: loop?.p99Ms,
143
+ evlMaxMs: loop?.maxMs,
179
144
  });
180
145
  }, HeadServer.PING_INTERVAL);
181
146
  }
@@ -206,7 +171,7 @@ export class HeadServer {
206
171
  continue;
207
172
  state.pendingLivenessBroadcast = true;
208
173
  getLogger().debug('Broadcasting device_pending (pong overdue)', { deviceId });
209
- this.broadcastDevicePending(state.userId, deviceId);
174
+ broadcastDevicePending(this, state.userId, deviceId);
210
175
  }
211
176
  }
212
177
  /** A missing pong alone does not prove a dead link: when the downlink is
@@ -226,22 +191,6 @@ export class HeadServer {
226
191
  }
227
192
  // ── End liveness sweep ──────────────────────────────────
228
193
  // --- Auth provider helpers ---
229
- getAuthProvider() {
230
- if (this.options.authProviders?.size) {
231
- return this.options.authProviders.values().next().value;
232
- }
233
- return this.options.authProvider;
234
- }
235
- getAuthProviderForMode(mode) {
236
- if (mode && this.options.authProviders?.has(mode)) {
237
- return this.options.authProviders.get(mode);
238
- }
239
- return this.getAuthProvider();
240
- }
241
- getGitHubClientId() {
242
- const ghProvider = this.findGitHubProvider();
243
- return ghProvider?.oauthConfigured ? ghProvider.getClientId() : undefined;
244
- }
245
194
  getVapidPublicKey() {
246
195
  return this.options.pushManager?.getVapidPublicKey();
247
196
  }
@@ -261,19 +210,6 @@ export class HeadServer {
261
210
  return undefined;
262
211
  return { brokerUrl: url, resource: 'voice/doubao' };
263
212
  }
264
- findGitHubProvider() {
265
- const provider = this.options.authProviders?.get('github') ?? this.options.authProvider;
266
- if (!provider)
267
- return undefined;
268
- if (provider instanceof GitHubAuthProvider)
269
- return provider;
270
- if ('inner' in provider) {
271
- const inner = provider.inner;
272
- if (inner instanceof GitHubAuthProvider)
273
- return inner;
274
- }
275
- return undefined;
276
- }
277
213
  // --- Connection management ---
278
214
  attach(server) {
279
215
  server.on('upgrade', (req, socket, head) => {
@@ -313,6 +249,11 @@ export class HeadServer {
313
249
  logger.debug('WebSocket connected', { ip });
314
250
  ws.on('pong', () => { this.onPongReceived(state); });
315
251
  ws.on('message', (data) => {
252
+ // An evicted socket stays open until its close handshake completes;
253
+ // nothing it sends may act for the device (its Pulse stream now
254
+ // belongs to the replacement connection).
255
+ if (state.evicted)
256
+ return;
316
257
  state.lastInboundAt = Date.now();
317
258
  try {
318
259
  const msg = JSON.parse(data.toString());
@@ -329,7 +270,9 @@ export class HeadServer {
329
270
  this.sendError(ws, 'Invalid JSON');
330
271
  }
331
272
  });
273
+ metrics().inc('conn.opened');
332
274
  ws.on('close', (code, reason) => {
275
+ metrics().inc('conn.closed');
333
276
  if (state.authTimer)
334
277
  clearTimeout(state.authTimer);
335
278
  const reasonStr = reason?.toString?.() || '';
@@ -353,7 +296,7 @@ export class HeadServer {
353
296
  closeReason: reasonStr,
354
297
  });
355
298
  if (disconnectedUserId) {
356
- this.broadcastDeviceLeft(disconnectedUserId, disconnectedDeviceId);
299
+ broadcastDeviceLeft(this, disconnectedUserId, disconnectedDeviceId);
357
300
  }
358
301
  }
359
302
  else {
@@ -382,44 +325,20 @@ export class HeadServer {
382
325
  async onMessage(ws, state, msg) {
383
326
  // One-shot pairing token request — before auth
384
327
  if (msg.type === 'request_pairing_token') {
385
- await this.handleRequestPairingToken(ws, state, msg);
328
+ await handleRequestPairingToken(this, ws, state, msg);
386
329
  return;
387
330
  }
388
331
  // Pre-auth: auth_info, auth, auth_response
389
332
  if (!state.authenticated) {
390
333
  if (msg.type === 'auth_info') {
391
334
  this.armAuthTimer(ws, state, this.options.loginScreenAuthTimeoutMs ?? DEFAULT_LOGIN_SCREEN_AUTH_TIMEOUT_MS);
392
- if (this.options.authBackend) {
393
- const info = this.options.authBackend.getAuthInfo();
394
- ws.send(JSON.stringify({
395
- type: 'auth_info_response',
396
- methods: info.methods,
397
- githubClientId: info.githubClientId,
398
- vapidPublicKey: info.vapidPublicKey ?? this.getVapidPublicKey(),
399
- }));
400
- }
401
- else {
402
- const methods = [];
403
- if (this.options.authProviders?.has('github')) {
404
- methods.push('github_token');
405
- const ghProvider = this.findGitHubProvider();
406
- if (ghProvider?.oauthConfigured)
407
- methods.push('github_oauth');
408
- }
409
- if (this.options.authProviders?.has('apikey'))
410
- methods.push('apikey');
411
- if (this.options.authProviders?.has('open'))
412
- methods.push('open');
413
- if (this.options.pairingEnabled !== false)
414
- methods.push('pairing');
415
- methods.push('challenge');
416
- ws.send(JSON.stringify({
417
- type: 'auth_info_response',
418
- methods,
419
- githubClientId: this.getGitHubClientId(),
420
- vapidPublicKey: this.getVapidPublicKey(),
421
- }));
422
- }
335
+ const info = this.authBackend.getAuthInfo();
336
+ ws.send(JSON.stringify({
337
+ type: 'auth_info_response',
338
+ methods: info.methods,
339
+ githubClientId: info.githubClientId,
340
+ vapidPublicKey: info.vapidPublicKey ?? this.getVapidPublicKey(),
341
+ }));
423
342
  return;
424
343
  }
425
344
  if (msg.type === 'auth') {
@@ -427,10 +346,10 @@ export class HeadServer {
427
346
  this.sendError(ws, 'Invalid auth message: device.name and device.role required');
428
347
  return;
429
348
  }
430
- await this.handleAuth(ws, state, msg);
349
+ await handleAuth(this, ws, state, msg);
431
350
  }
432
351
  else if (msg.type === 'auth_response' && state.pendingNonce) {
433
- this.handleChallengeResponse(ws, state, msg);
352
+ handleChallengeResponse(this, ws, state, msg);
434
353
  }
435
354
  else {
436
355
  this.sendError(ws, 'Must authenticate first');
@@ -439,7 +358,7 @@ export class HeadServer {
439
358
  }
440
359
  // Authenticated messages
441
360
  if (msg.type === 'create_pairing_token') {
442
- this.handleCreatePairingToken(ws, state);
361
+ await handleCreatePairingToken(this, ws, state);
443
362
  return;
444
363
  }
445
364
  if (msg.type === 'unicast') {
@@ -465,8 +384,16 @@ export class HeadServer {
465
384
  // endpoint/disk-growth amplifier from a single client.
466
385
  if (to !== '' && to !== HEAD_PULSE_TARGET) {
467
386
  const target = this.storage.getDevice(to);
468
- if (!target || target.userId !== state.userId) {
387
+ // Same account, and only across roles (app ⇄ tentacle): no feature
388
+ // sends app→app or tentacle→tentacle, so such a frame is not trusted.
389
+ const sender = this.storage.getDevice(state.deviceId);
390
+ if (!target || target.userId !== state.userId || !sender || target.role === sender.role) {
469
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: [] });
470
397
  return;
471
398
  }
472
399
  }
@@ -502,18 +429,20 @@ export class HeadServer {
502
429
  this.sendError(ws, `multicast target count must be between 1 and ${MAX_MULTICAST_TARGETS}`);
503
430
  return;
504
431
  }
505
- for (const targetId of requested) {
432
+ const allowed = requested.filter((targetId) => {
506
433
  const target = this.storage.getDevice(targetId);
507
- if (targetId === state.deviceId
508
- || targetId === HEAD_PULSE_TARGET
509
- || !target
510
- || target.userId !== state.userId
511
- || target.role !== 'app') {
512
- this.sendError(ws, 'multicast contains a forbidden target');
513
- return;
514
- }
515
- }
516
- 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));
517
446
  this.pulseHub.onPulseEnvelope(state.deviceId, {
518
447
  pulse: msg.pulse,
519
448
  to: onlineTargets,
@@ -526,8 +455,10 @@ export class HeadServer {
526
455
  if (typeof msg.pulse === 'string' && state.deviceId) {
527
456
  // A pushPreview may ride the same pulse envelope — fire it for offline
528
457
  // devices (the reliable message itself is delivered by the hub).
529
- if (msg.pushPreview)
530
- this.firePushPreview(state, msg.pushPreview);
458
+ // Push previews are tentacle-originated (same rule as dispatch_push).
459
+ if (msg.pushPreview && this.storage.getDevice(state.deviceId)?.role === 'tentacle') {
460
+ firePushPreview(this, state, msg.pushPreview);
461
+ }
531
462
  this.pulseHub.onPulseEnvelope(state.deviceId, { pulse: msg.pulse });
532
463
  return;
533
464
  }
@@ -536,10 +467,10 @@ export class HeadServer {
536
467
  }
537
468
  // Relay-terminated control ops (update_preferences / remove_device /
538
469
  // (un)register_push_token). Arrive via pulse deliver-to-self (deliverPulseToSelf).
539
- if (this.handleControlMessage(ws, state, msg))
470
+ if (handleControlMessage(this, ws, state, msg))
540
471
  return;
541
472
  if (msg.type === 'request_voice_lease') {
542
- this.handleRequestVoiceLease(ws, state, msg);
473
+ handleRequestVoiceLease(this, ws, state, msg);
543
474
  return;
544
475
  }
545
476
  if (msg.type === 'ping') {
@@ -553,113 +484,6 @@ export class HeadServer {
553
484
  this.sendError(ws, `Unknown message type: ${msg.type}`);
554
485
  }
555
486
  // --- Voice lease issuance ---
556
- handleRequestVoiceLease(ws, state, msg) {
557
- const logger = getLogger();
558
- const userId = state.userId;
559
- const deviceId = state.deviceId;
560
- if (!userId || !deviceId) {
561
- ws.send(JSON.stringify({
562
- type: 'voice_lease_denied',
563
- reason: 'invalid_request',
564
- detail: 'Not authenticated',
565
- }));
566
- return;
567
- }
568
- const issuer = this.options.leaseIssuer;
569
- if (!issuer) {
570
- this.sendControlToDevice(deviceId, {
571
- type: 'voice_lease_denied',
572
- reason: 'not_entitled',
573
- detail: 'Voice lease issuance is not enabled on this head',
574
- });
575
- return;
576
- }
577
- // Validate request shape.
578
- const requestedDeviceId = typeof msg.deviceId === 'string' ? msg.deviceId : undefined;
579
- const requestedResource = typeof msg.resource === 'string' ? msg.resource : undefined;
580
- if (!requestedDeviceId || !requestedResource) {
581
- this.sendControlToDevice(deviceId, {
582
- type: 'voice_lease_denied',
583
- reason: 'invalid_request',
584
- detail: 'Missing deviceId or resource',
585
- });
586
- return;
587
- }
588
- // Lease may only be requested for the authenticated device — no proxying.
589
- if (requestedDeviceId !== deviceId) {
590
- this.sendControlToDevice(deviceId, {
591
- type: 'voice_lease_denied',
592
- reason: 'invalid_request',
593
- detail: 'deviceId does not match authenticated device',
594
- });
595
- return;
596
- }
597
- if (!VALID_VOICE_RESOURCES.has(requestedResource)) {
598
- this.sendControlToDevice(deviceId, {
599
- type: 'voice_lease_denied',
600
- reason: 'invalid_request',
601
- detail: `Unknown resource: ${String(requestedResource)}`,
602
- });
603
- return;
604
- }
605
- // A lease is a long-lived credential for this device; the audio budget
606
- // is granted incrementally by usage reports, charged to the UTC day the
607
- // audio is used. So issuance only checks that anything is left today.
608
- const nowSec = Math.floor(Date.now() / 1000);
609
- const dailyCap = this.options.voiceDailyQuotaSec ?? DEFAULT_VOICE_DAILY_QUOTA_SEC;
610
- this.storage.revokePendingVoiceLeases(userId, deviceId, requestedResource, nowSec);
611
- const accountedToday = this.storage.voiceSecondsAccountedToday(userId, nowSec);
612
- if (dailyCap - accountedToday < MIN_VOICE_LEASE_GRANT_SEC) {
613
- logger.info('Voice lease denied: daily quota exhausted', {
614
- userId, deviceId, accountedToday, dailyCap,
615
- });
616
- this.sendControlToDevice(deviceId, {
617
- type: 'voice_lease_denied',
618
- reason: 'quota_exhausted',
619
- detail: `Daily quota reached (${accountedToday}/${dailyCap}s)`,
620
- });
621
- return;
622
- }
623
- const ttl = this.options.voiceLeaseTtlSec ?? DEFAULT_VOICE_LEASE_TTL_SEC;
624
- // Signed ceiling for one lease: all the UTC days it can span. The real
625
- // limit is the daily grant Head hands out on each usage report.
626
- const grant = dailyCap * (Math.ceil(ttl / 86_400) + 1);
627
- const lease = issuer.issue({
628
- userId,
629
- deviceId,
630
- quotaSeconds: grant,
631
- ttlSeconds: ttl,
632
- resource: requestedResource,
633
- nowUnixSec: nowSec,
634
- });
635
- try {
636
- this.storage.recordVoiceLease({
637
- jti: lease.payload.jti,
638
- userId,
639
- deviceId,
640
- resource: requestedResource,
641
- quotaSeconds: grant,
642
- issuedAtUnixSec: lease.payload.iat,
643
- expiresAtUnixSec: lease.payload.exp,
644
- });
645
- }
646
- catch (err) {
647
- logger.error('Voice lease persist failed', {
648
- userId, deviceId, jti: lease.payload.jti, error: err.message,
649
- });
650
- this.sendControlToDevice(deviceId, {
651
- type: 'voice_lease_denied',
652
- reason: 'invalid_request',
653
- detail: 'Lease persistence failed',
654
- });
655
- return;
656
- }
657
- logger.info('Voice lease issued', {
658
- userId, deviceId, jti: lease.payload.jti,
659
- quotaSeconds: grant, ttlSec: ttl, accountedToday, dailyCap,
660
- });
661
- this.sendControlToDevice(deviceId, { type: 'voice_lease_grant', lease });
662
- }
663
487
  // --- Routing ---
664
488
  /** Send a pulse frame (control or forwarded data) to a device's socket, as a
665
489
  * minimal unicast envelope carrying only the `pulse` field. Returns true if
@@ -670,9 +494,20 @@ export class HeadServer {
670
494
  trace('HUB-TX-WS', { to: deviceId, online, pulseB64Len: pulseB64.length });
671
495
  if (!online)
672
496
  return false;
497
+ // Backpressure: a connected but stalled client would otherwise grow the
498
+ // socket's send buffer without bound. Drop the connection instead; every
499
+ // reliable frame is still in the pulse outbox and resends on reconnect.
500
+ if (ws.bufferedAmount > MAX_WS_BUFFERED_BYTES) {
501
+ metrics().inc('conn.stalledClosed');
502
+ getLogger().warn('Closing stalled connection (send buffer full)', { deviceId, bufferedBytes: ws.bufferedAmount });
503
+ ws.terminate();
504
+ return false;
505
+ }
673
506
  // `to` lets the receiver's own pulse layer know which stream this is; blob
674
507
  // is empty because the payload (if any) is inside the pulse frame.
675
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);
676
511
  return true;
677
512
  }
678
513
  /** Send a head-ORIGINATED control message to a device reliably over pulse
@@ -717,99 +552,7 @@ export class HeadServer {
717
552
  }
718
553
  // handleControlMessage whitelists the 4 relay-terminated types and returns
719
554
  // false for anything else, so the self-channel cannot tunnel unicast/auth/etc.
720
- this.handleControlMessage(ws, state, msg);
721
- }
722
- /** The relay-terminated control ops, callable from BOTH the raw-WS dispatch
723
- * (onMessage) and the pulse deliver-to-self path. Returns true if handled. */
724
- handleControlMessage(ws, state, msg) {
725
- switch (msg.type) {
726
- case 'update_preferences':
727
- this.handleUpdatePreferences(ws, state, msg);
728
- return true;
729
- case 'update_voice_vocabulary':
730
- this.handleUpdateVoiceVocabulary(state, msg);
731
- return true;
732
- case 'remove_device':
733
- this.handleRemoveDevice(ws, state, msg.deviceId);
734
- return true;
735
- case 'register_push_token':
736
- this.handleRegisterPushToken(ws, state, msg);
737
- return true;
738
- case 'unregister_push_token':
739
- this.handleUnregisterPushToken(ws, state, msg);
740
- return true;
741
- case 'dispatch_push': {
742
- const sender = state.deviceId ? this.storage.getDevice(state.deviceId) : undefined;
743
- const payload = msg.payload;
744
- if (sender?.role !== 'tentacle' || !payload?.preview)
745
- return true;
746
- this.firePushPreview(state, payload.preview);
747
- return true;
748
- }
749
- default: return false;
750
- }
751
- }
752
- /** Persist a preferences update and echo the merged result to the sender + the
753
- * user's other online devices. */
754
- handleUpdatePreferences(ws, state, msg) {
755
- if (!state.userId)
756
- return;
757
- const prefs = msg.preferences;
758
- if (!prefs || typeof prefs !== 'object')
759
- return;
760
- this.storage.updatePreferences(state.userId, prefs);
761
- // Read back the full merged preferences
762
- const fullUser = this.storage.getUser(state.userId);
763
- const merged = publicPreferences(fullUser?.preferences ?? prefs);
764
- const confirmation = { type: 'preferences_updated', preferences: merged };
765
- // Confirm to the sender + fan out to the user's other devices — all over pulse.
766
- if (state.deviceId)
767
- this.sendControlToDevice(state.deviceId, confirmation);
768
- const userDevices = (() => {
769
- try {
770
- return this.storage.getDevicesByUser(state.userId);
771
- }
772
- catch {
773
- return [];
774
- }
775
- })();
776
- for (const d of userDevices) {
777
- if (d.id === state.deviceId)
778
- continue;
779
- const other = this.connections.get(d.id);
780
- if (!other || other.readyState !== WebSocket.OPEN)
781
- continue;
782
- this.sendControlToDevice(d.id, confirmation);
783
- }
784
- }
785
- /** Account data, not session data: no tentacle is involved. The sender
786
- * always gets the resulting list back (its acknowledgement); other online
787
- * apps of the account get it only when something was applied. */
788
- handleUpdateVoiceVocabulary(state, msg) {
789
- if (!state.userId || !state.deviceId)
790
- return;
791
- const requestId = typeof msg.requestId === 'string' ? msg.requestId.slice(0, 64) : undefined;
792
- const ops = parseVoiceWordOps(msg.ops);
793
- let words;
794
- try {
795
- words = ops?.length
796
- ? this.storage.updateVoiceVocabulary(state.userId, ops)
797
- : this.storage.getVoiceVocabulary(state.userId);
798
- }
799
- catch (err) {
800
- // Pulse self-delivery has no outer try/catch. No reply: the client keeps
801
- // the ops in its outbox and retries.
802
- getLogger().warn('Voice vocabulary update failed', { error: err.message });
803
- return;
804
- }
805
- this.sendControlToDevice(state.deviceId, { type: 'voice_vocabulary_updated', requestId, words });
806
- if (!ops?.length)
807
- return;
808
- for (const d of this.storage.getDevicesByUser(state.userId)) {
809
- if (d.id !== state.deviceId && d.role === 'app' && this.connections.get(d.id)?.readyState === WebSocket.OPEN) {
810
- this.sendControlToDevice(d.id, { type: 'voice_vocabulary_updated', words });
811
- }
812
- }
555
+ handleControlMessage(this, ws, state, msg);
813
556
  }
814
557
  /** Fan-out targets for a pulse broadcast from `fromDevice`: all OTHER
815
558
  * ONLINE devices of the same user. Offline devices are excluded because
@@ -839,46 +582,6 @@ export class HeadServer {
839
582
  }
840
583
  return targets;
841
584
  }
842
- /** Notify offline devices from a pushPreview side-channel. The preview rides the
843
- * same pulse broadcast envelope as the reliable message. */
844
- firePushPreview(state, pushPreview) {
845
- const senderUserId = state.userId;
846
- const senderDeviceId = state.deviceId;
847
- if (!senderUserId || !senderDeviceId || !pushPreview) {
848
- trace('PUSH-SKIP', { reason: !senderUserId ? 'no-user' : !senderDeviceId ? 'no-device' : 'no-preview' });
849
- return;
850
- }
851
- // Online native clients that post their own notifications (macOS) get the
852
- // same encrypted preview over their live connection.
853
- for (const token of this.storage.getPushTokensForOfflineDevices(senderUserId, [])) {
854
- if (token.provider !== 'local' || token.deviceId === senderDeviceId)
855
- continue;
856
- if (!this.connections.has(token.deviceId))
857
- continue;
858
- const key = pushPreview.keys[token.deviceId];
859
- if (!key)
860
- continue;
861
- this.sendControlToDevice(token.deviceId, { type: 'notification_preview', payload: { blob: pushPreview.blob, key } });
862
- trace('PUSH-LOCAL', { deviceId: token.deviceId });
863
- }
864
- if (!this.options.pushManager) {
865
- trace('PUSH-SKIP', { reason: 'no-pushmgr' });
866
- return;
867
- }
868
- const onlineDeviceIds = [senderDeviceId];
869
- let offlineCount = 0;
870
- for (const device of this.storage.getDevicesByUser(senderUserId)) {
871
- if (device.id === senderDeviceId)
872
- continue;
873
- if (this.connections.get(device.id))
874
- onlineDeviceIds.push(device.id);
875
- else
876
- offlineCount++;
877
- }
878
- trace('PUSH-FIRE', { sender: senderDeviceId, onlineCount: onlineDeviceIds.length, offlineCount, previewKeys: Object.keys(pushPreview.keys) });
879
- this.options.pushManager.sendToOfflineDevices(senderUserId, onlineDeviceIds, pushPreview)
880
- .catch(err => getLogger().warn('Push notification send failed', { error: err.message }));
881
- }
882
585
  removeConnection(deviceId) {
883
586
  const ws = this.connections.get(deviceId);
884
587
  const userId = this.userByDevice.get(deviceId);
@@ -911,680 +614,16 @@ export class HeadServer {
911
614
  this.storage.touchDeviceLastSeen(deviceId);
912
615
  }
913
616
  catch { /* storage may be closed */ }
914
- this.broadcastDeviceLeft(userId, deviceId);
915
- }
916
- }
917
- sendAuthError(ws, code, message, redirect, deviceId) {
918
- if (ws.readyState === WebSocket.OPEN) {
919
- ws.send(JSON.stringify({ type: 'auth_error', code, message, ...(redirect && { redirect }), ...(deviceId && { deviceId }) }));
617
+ broadcastDeviceLeft(this, userId, deviceId);
920
618
  }
921
619
  }
922
620
  // --- Auth ---
923
- async handleAuth(ws, state, msg) {
924
- state.pulseProgressAck = msg.device?.pulseProgressAck === true;
925
- const logger = getLogger();
926
- const { auth } = msg;
927
- // If auth backend is configured, delegate everything to it
928
- if (this.options.authBackend) {
929
- if (auth.method === 'challenge') {
930
- // Challenge is multi-step — start here, verify in handleChallengeResponse
931
- const result = await this.options.authBackend.startChallenge(auth.deviceId, msg.device.encryptionKey, this.options.region);
932
- if (!result.ok) {
933
- this.sendAuthError(ws, result.code, result.message, result.code === 'wrong_region' ? result.redirect : undefined);
934
- return;
935
- }
936
- state.pendingNonce = result.nonce;
937
- state.pendingDeviceId = result.deviceId;
938
- state.pendingDeviceInfo = { encryptionKey: msg.device.encryptionKey };
939
- state.pendingAuthMethod = 'challenge';
940
- logger.debug('Issuing auth challenge (via backend)', { deviceId: auth.deviceId });
941
- ws.send(JSON.stringify({ type: 'auth_challenge', nonce: result.nonce }));
942
- return;
943
- }
944
- const result = await this.options.authBackend.authenticate(auth, msg.device, this.options.region);
945
- this.handleBackendAuthResult(ws, state, result);
946
- return;
947
- }
948
- // Legacy: inline auth (no backend configured)
949
- switch (auth.method) {
950
- case 'pairing': {
951
- this.handlePairingAuth(ws, state, auth.token, msg);
952
- return;
953
- }
954
- case 'challenge': {
955
- const device = this.storage.getDevice(auth.deviceId);
956
- if (device && device.publicKey) {
957
- const nonce = randomBytes(32).toString('hex');
958
- state.pendingNonce = nonce;
959
- state.pendingDeviceId = auth.deviceId;
960
- state.pendingDeviceInfo = { encryptionKey: msg.device.encryptionKey };
961
- state.pendingAuthMethod = 'challenge';
962
- logger.debug('Issuing auth challenge', { deviceId: auth.deviceId });
963
- ws.send(JSON.stringify({ type: 'auth_challenge', nonce }));
964
- return;
965
- }
966
- this.sendAuthError(ws, 'unknown_device', 'Unknown device');
967
- return;
968
- }
969
- case 'github_token': {
970
- const provider = this.getAuthProviderForMode('github');
971
- const result = await provider.authenticate({ token: auth.token, ip: state.ip });
972
- if (!result.ok) {
973
- logger.warn('Auth rejected', { method: 'github_token', ip: state.ip, reason: result.message });
974
- // A GitHub outage or network error is not a bad credential (G4).
975
- this.sendAuthError(ws, result.retryable ? 'service_unavailable' : 'auth_rejected', result.message);
976
- return;
977
- }
978
- this.completeAuth(ws, state, result.user, msg, 'github_token');
979
- return;
980
- }
981
- case 'github_oauth': {
982
- const provider = this.getAuthProviderForMode('github');
983
- const oauthAuth = auth;
984
- const result = await provider.authenticate({
985
- githubCode: oauthAuth.code,
986
- codeVerifier: typeof oauthAuth.codeVerifier === 'string' ? oauthAuth.codeVerifier : undefined,
987
- redirectUri: typeof oauthAuth.redirectUri === 'string' ? oauthAuth.redirectUri : undefined,
988
- ip: state.ip,
989
- });
990
- if (!result.ok) {
991
- logger.warn('Auth rejected', { method: 'github_oauth', ip: state.ip, reason: result.message });
992
- // A GitHub outage or network error is not a bad credential (G4).
993
- this.sendAuthError(ws, result.retryable ? 'service_unavailable' : 'auth_rejected', result.message);
994
- return;
995
- }
996
- this.completeAuth(ws, state, result.user, msg, 'github_oauth');
997
- return;
998
- }
999
- case 'apikey': {
1000
- const provider = this.getAuthProviderForMode('apikey');
1001
- const result = await provider.authenticate({ token: auth.key, ip: state.ip });
1002
- if (!result.ok) {
1003
- logger.warn('Auth rejected', { method: 'apikey', ip: state.ip, reason: result.message });
1004
- // A GitHub outage or network error is not a bad credential (G4).
1005
- this.sendAuthError(ws, result.retryable ? 'service_unavailable' : 'auth_rejected', result.message);
1006
- return;
1007
- }
1008
- this.completeAuth(ws, state, result.user, msg, 'apikey');
1009
- return;
1010
- }
1011
- case 'open': {
1012
- const provider = this.getAuthProviderForMode('open');
1013
- const result = await provider.authenticate({ token: auth.sharedKey, ip: state.ip });
1014
- if (!result.ok) {
1015
- logger.warn('Auth rejected', { method: 'open', ip: state.ip, reason: result.message });
1016
- // A GitHub outage or network error is not a bad credential (G4).
1017
- this.sendAuthError(ws, result.retryable ? 'service_unavailable' : 'auth_rejected', result.message);
1018
- return;
1019
- }
1020
- this.completeAuth(ws, state, result.user, msg, 'open');
1021
- return;
1022
- }
1023
- default: {
1024
- const method = auth.method;
1025
- this.sendAuthError(ws, 'unknown_auth_method', `Unknown auth method: ${method}`);
1026
- }
1027
- }
1028
- }
1029
- handlePairingAuth(ws, state, pairingToken, msg) {
1030
- const logger = getLogger();
1031
- if (!(this.options.pairingEnabled ?? true)) {
1032
- this.sendAuthError(ws, 'pairing_disabled', 'Pairing is disabled.');
1033
- return;
1034
- }
1035
- const tokenData = this.pairingTokens.get(pairingToken);
1036
- if (!tokenData || tokenData.expiresAt < Date.now()) {
1037
- if (tokenData)
1038
- this.pairingTokens.delete(pairingToken);
1039
- logger.warn('Pairing auth failed: invalid or expired token', { ip: state.ip });
1040
- this.sendAuthError(ws, 'invalid_pairing_token', 'Invalid or expired pairing token');
1041
- return;
1042
- }
1043
- // Consume token (single-use)
1044
- this.pairingTokens.delete(pairingToken);
1045
- const user = this.storage.getUser(tokenData.userId);
1046
- if (!user) {
1047
- this.sendAuthError(ws, 'user_not_found', 'User not found');
1048
- return;
1049
- }
1050
- const authUser = { id: user.userId, login: user.username, provider: user.provider, email: user.email };
1051
- this.completeAuth(ws, state, authUser, msg, 'pairing');
1052
- }
1053
- handleChallengeResponse(ws, state, msg) {
1054
- const logger = getLogger();
1055
- const deviceId = state.pendingDeviceId;
1056
- const nonce = state.pendingNonce;
1057
- const pendingInfo = state.pendingDeviceInfo;
1058
- state.pendingNonce = undefined;
1059
- state.pendingDeviceId = undefined;
1060
- state.pendingDeviceInfo = undefined;
1061
- if (!deviceId || !nonce) {
1062
- this.sendAuthError(ws, 'no_pending_challenge', 'No pending challenge');
1063
- return;
1064
- }
1065
- // If auth backend is configured, delegate verification
1066
- if (this.options.authBackend) {
1067
- this.options.authBackend.verifyChallenge(deviceId, nonce, msg.signature, pendingInfo?.encryptionKey, this.options.region).then(result => {
1068
- this.handleBackendAuthResult(ws, state, result);
1069
- }).catch(err => {
1070
- // A thrown error means the auth backend itself was unreachable / timed
1071
- // out (network, 5xx, abort) — NOT that the signature was invalid. A
1072
- // deterministic auth failure comes back as a resolved AuthError outcome
1073
- // above. Reporting this as auth_rejected made the arm treat a transient
1074
- // backend outage as a permanent credential failure and wipe the paired
1075
- // device, forcing a full re-pair. Surface it as auth_unavailable so the
1076
- // client retries instead of destroying a valid session.
1077
- logger.error('Auth backend verifyChallenge failed', { error: err.message });
1078
- this.sendAuthError(ws, 'service_unavailable', 'Authentication service unavailable');
1079
- });
1080
- return;
1081
- }
1082
- // Legacy: inline challenge verification
1083
- const device = this.storage.getDevice(deviceId);
1084
- if (!device || !device.publicKey) {
1085
- this.sendAuthError(ws, 'device_not_found', 'Device not found');
1086
- return;
1087
- }
1088
- const publicKeyPem = importPublicKey(device.publicKey);
1089
- const valid = verifySignature(nonce, msg.signature, publicKeyPem);
1090
- if (!valid) {
1091
- logger.warn('Challenge-response auth failed', { deviceId, ip: state.ip });
1092
- this.sendAuthError(ws, 'invalid_signature', 'Invalid signature');
1093
- return;
1094
- }
1095
- // Update encryption key if provided
1096
- const encryptionKey = pendingInfo?.encryptionKey ?? device.encryptionKey ?? undefined;
1097
- this.storage.upsertDevice(deviceId, device.userId, device.name, device.role, device.kind ?? undefined, device.publicKey ?? undefined, encryptionKey);
1098
- const user = this.storage.getUser(device.userId);
1099
- if (!user) {
1100
- this.sendAuthError(ws, 'user_not_found', 'User not found');
1101
- return;
1102
- }
1103
- // Register connection
1104
- state.authenticated = true;
1105
- state.authenticatedAt = Date.now();
1106
- if (state.authTimer) {
1107
- clearTimeout(state.authTimer);
1108
- state.authTimer = undefined;
1109
- }
1110
- state.deviceId = deviceId;
1111
- state.userId = user.userId;
1112
- this.evictPreviousConnection(deviceId, ws);
1113
- this.connections.set(deviceId, ws);
1114
- this.userByDevice.set(deviceId, user.userId);
1115
- trace('DEV-CONNECT', { device: deviceId, user: user.userId, auth: 'challenge' });
1116
- logger.info('Device authenticated via challenge-response', { deviceId, ip: state.ip });
1117
- this.completeAuthHandshake(ws, {
1118
- deviceId,
1119
- userId: user.userId,
1120
- user: {
1121
- id: user.userId,
1122
- login: user.username,
1123
- provider: user.provider,
1124
- email: user.email,
1125
- },
1126
- authMethod: 'challenge',
1127
- });
1128
- }
1129
- /**
1130
- * Handle a result from AuthBackend — registers connection state and sends response.
1131
- * Used for both initial auth and challenge-response verification.
1132
- */
1133
- handleBackendAuthResult(ws, state, result) {
1134
- const logger = getLogger();
1135
- if (!result.ok) {
1136
- const redirect = result.code === 'wrong_region' ? result.redirect : undefined;
1137
- const deviceId = result.code === 'wrong_region' ? result.deviceId : undefined;
1138
- this.sendAuthError(ws, result.code, result.message, redirect, deviceId);
1139
- return;
1140
- }
1141
- // Register connection
1142
- state.authenticated = true;
1143
- state.authenticatedAt = Date.now();
1144
- if (state.authTimer) {
1145
- clearTimeout(state.authTimer);
1146
- state.authTimer = undefined;
1147
- }
1148
- state.deviceId = result.deviceId;
1149
- state.userId = result.userId;
1150
- this.evictPreviousConnection(result.deviceId, ws);
1151
- this.connections.set(result.deviceId, ws);
1152
- this.userByDevice.set(result.deviceId, result.userId);
1153
- trace('DEV-CONNECT', { device: result.deviceId, user: result.userId, auth: 'backend' });
1154
- logger.info('Device authenticated (via backend)', {
1155
- deviceId: result.deviceId,
1156
- user: result.user.login,
1157
- method: result.authMethod,
1158
- ip: state.ip,
1159
- });
1160
- // Mirror user + devices into local storage so edge-local operations
1161
- // (broadcastDeviceJoined, getDeviceSummaries, etc.) work even when auth
1162
- // is delegated to a remote backend.
1163
- try {
1164
- this.storage.upsertUser(result.user.id, result.user.login, result.user.provider, result.user.email);
1165
- for (const d of result.devices) {
1166
- try {
1167
- this.storage.upsertDevice(d.id, result.userId, d.name, d.role, d.kind ?? undefined, d.publicKey ?? undefined, d.encryptionKey ?? undefined);
1168
- }
1169
- catch (err) {
1170
- logger.warn('Failed to mirror device locally', { deviceId: d.id, error: err.message });
1171
- }
1172
- }
1173
- }
1174
- catch (err) {
1175
- logger.warn('Failed to mirror user locally', { userId: result.userId, error: err.message });
1176
- }
1177
- this.completeAuthHandshake(ws, {
1178
- deviceId: result.deviceId,
1179
- userId: result.userId,
1180
- user: result.user,
1181
- authMethod: result.authMethod,
1182
- devices: result.devices,
1183
- githubClientId: result.githubClientId,
1184
- vapidPublicKey: result.vapidPublicKey,
1185
- });
1186
- }
1187
- /**
1188
- * Common post-auth completion logic, used by every auth path.
1189
- *
1190
- * Responsibilities:
1191
- * - Read user preferences from local storage and merge into the user object.
1192
- * - Flush any locally-queued pending messages for this device (offline-queue).
1193
- * - Build and send the `auth_ok` response.
1194
- * - Broadcast `device_joined` to the user's other connected devices.
1195
- *
1196
- * Each caller is responsible for path-specific work BEFORE calling this:
1197
- * validating credentials, persisting user/device records, setting up
1198
- * ClientState, populating `connections` / `userByDevice` maps, and logging
1199
- * the path-specific "authenticated via X" line.
1200
- *
1201
- * Centralising the tail eliminates a class of bugs where new auth paths
1202
- * (notably the multi-region delegated-backend path) silently diverge from
1203
- * the established post-auth flow.
1204
- */
1205
- completeAuthHandshake(ws, params) {
1206
- // Merge preferences. Local storage takes precedence (it captures per-edge
1207
- // overrides the backend might not know about), but fall back to the
1208
- // backend-supplied preferences if local has none — e.g. on a user's very
1209
- // first auth to this edge, before upsertUser has been called or when the
1210
- // backend is the canonical source of truth.
1211
- //
1212
- // Why this matters: storage.upsertUser only writes username/provider/email,
1213
- // NOT preferences. So a brand-new user record reads back with
1214
- // preferences=undefined. Without this fallback, backend preferences would
1215
- // be silently dropped at every first auth.
1216
- const fullUser = this.storage.getUser(params.userId);
1217
- const userResponse = {
1218
- ...params.user,
1219
- preferences: publicPreferences(fullUser?.preferences ?? params.user.preferences),
1220
- };
1221
- // Devices list. If the caller provided one (edge mode), recompute online
1222
- // status against our current connections map. Otherwise derive locally.
1223
- const devices = params.devices
1224
- ? params.devices.map(d => ({ ...d, online: this.connections.has(d.id) }))
1225
- : this.getDeviceSummaries(params.userId);
1226
- const deviceRole = devices.find((device) => device.id === params.deviceId)?.role
1227
- ?? this.storage.getDevice(params.deviceId)?.role;
1228
- ws.send(JSON.stringify({
1229
- type: 'auth_ok',
1230
- deviceId: params.deviceId,
1231
- authMethod: params.authMethod,
1232
- user: userResponse,
1233
- devices,
1234
- githubClientId: params.githubClientId ?? this.getGitHubClientId(),
1235
- vapidPublicKey: params.vapidPublicKey ?? this.getVapidPublicKey(),
1236
- relayVersion: this.options.version,
1237
- pulseAckBytes: PULSE_ACK_EVERY_BYTES,
1238
- // Apps only: a tentacle has no use for the user's word list.
1239
- ...(deviceRole === 'app' && { voiceVocabulary: this.storage.getVoiceVocabulary(params.userId) }),
1240
- ...(this.getVoiceCapability() && { voice: this.getVoiceCapability() }),
1241
- }));
1242
- // Clients only accept Pulse envelopes after processing auth_ok. Starting
1243
- // the endpoint before auth_ok loses the initial hello, leaving peerEpoch
1244
- // empty and making a later relay restart indistinguishable from the first
1245
- // connection. That stale recvCursor then drops the restarted seq=1..N as
1246
- // duplicates. Keep this ordering centralized for every auth path.
1247
- //
1248
- // Apps have explicit reconnect authorities (fresh session_list,
1249
- // subscription snapshot, and history ranges). If their HELLO carries a new
1250
- // process epoch, fence the preceding process's non-durable downlink so the
1251
- // new UI cannot animate through old live events. Tentacle downlinks carry
1252
- // commands and retain ordinary Pulse resume semantics across process epochs.
1253
- this.pulseHub.onDeviceConnected(params.deviceId, {
1254
- discardPreviousProcessNonDurable: deviceRole === 'app',
1255
- });
1256
- this.broadcastDeviceJoined(params.userId, params.deviceId);
1257
- }
1258
- /**
1259
- * A device is about to register `ws` as its connection. If the SAME device id
1260
- * already had a socket, that older socket is now orphaned: once we overwrite
1261
- * the map entry its close handler hits the reconnect-guard and skips cleanup,
1262
- * and the ping timer (which only walks `connections`) never visits it again.
1263
- * It would sit in `clients` holding its ClientState until TCP finally gives
1264
- * up. Retire both the WebSocket and its Pulse link explicitly before the new
1265
- * connection is installed.
1266
- *
1267
- * Must be called BEFORE `connections.set(deviceId, ws)`.
1268
- */
1269
- evictPreviousConnection(deviceId, ws) {
1270
- const previous = this.connections.get(deviceId);
1271
- if (!previous || previous === ws)
1272
- return;
1273
- getLogger().info('Replacing existing connection for device', { deviceId });
1274
- // The old close callback will see the replacement in `connections` and skip
1275
- // its guarded cleanup. Publish the Pulse disconnect here so capability and
1276
- // connection-scoped state cannot leak across the replacement boundary.
1277
- this.pulseHub.onDeviceDisconnected(deviceId);
1278
- this.clients.delete(previous);
1279
- try {
1280
- previous.terminate();
1281
- }
1282
- catch { /* best effort */ }
1283
- }
1284
- completeAuth(ws, state, user, msg, authMethod) {
1285
- const logger = getLogger();
1286
- // Persist user
1287
- this.storage.upsertUser(user.id, user.login, user.provider, user.email);
1288
- // Register device
1289
- const deviceId = msg.device.deviceId ?? `dev_${uuid().slice(0, 12)}`;
1290
- try {
1291
- this.storage.upsertDevice(deviceId, user.id, msg.device.name, msg.device.role, msg.device.kind, msg.device.publicKey, msg.device.encryptionKey);
1292
- }
1293
- catch (err) {
1294
- logger.warn('Device registration failed', { ip: state.ip, error: err.message });
1295
- this.sendAuthError(ws, 'device_registration_failed', err.message);
1296
- return;
1297
- }
1298
- state.authenticated = true;
1299
- state.authenticatedAt = Date.now();
1300
- if (state.authTimer) {
1301
- clearTimeout(state.authTimer);
1302
- state.authTimer = undefined;
1303
- }
1304
- state.deviceId = deviceId;
1305
- state.userId = user.id;
1306
- this.evictPreviousConnection(deviceId, ws);
1307
- this.connections.set(deviceId, ws);
1308
- this.userByDevice.set(deviceId, user.id);
1309
- trace('DEV-CONNECT', { device: deviceId, user: user.id, auth: 'inline' });
1310
- logger.info('Device authenticated', {
1311
- deviceId,
1312
- name: msg.device.name,
1313
- role: msg.device.role,
1314
- user: user.login,
1315
- ip: state.ip,
1316
- });
1317
- this.completeAuthHandshake(ws, {
1318
- deviceId,
1319
- userId: user.id,
1320
- user: {
1321
- id: user.id,
1322
- login: user.login,
1323
- provider: user.provider,
1324
- email: user.email,
1325
- },
1326
- authMethod,
1327
- });
1328
- }
1329
621
  // --- Pairing tokens (in-memory) ---
1330
- handleCreatePairingToken(ws, state) {
1331
- const logger = getLogger();
1332
- if (!(this.options.pairingEnabled ?? true)) {
1333
- this.sendError(ws, 'Pairing is disabled on this relay');
1334
- return;
1335
- }
1336
- if (!state.userId) {
1337
- this.sendError(ws, 'Not authenticated');
1338
- return;
1339
- }
1340
- if (this.options.authBackend) {
1341
- try {
1342
- const result = this.options.authBackend.createPairingToken(state.userId);
1343
- logger.info('Pairing token created (via backend)', { userId: state.userId });
1344
- ws.send(JSON.stringify({ type: 'pairing_token_created', token: result.token, expiresIn: result.expiresIn }));
1345
- }
1346
- catch {
1347
- // Remote backend needs async — fall through to error
1348
- this.sendError(ws, 'Pairing not available in remote mode');
1349
- }
1350
- return;
1351
- }
1352
- const token = `pt_${randomBytes(32).toString('hex')}`;
1353
- const ttl = this.options.pairingTtl ?? 300;
1354
- this.pairingTokens.set(token, {
1355
- userId: state.userId,
1356
- expiresAt: Date.now() + ttl * 1000,
1357
- });
1358
- logger.info('Pairing token created', { userId: state.userId, ttl });
1359
- ws.send(JSON.stringify({
1360
- type: 'pairing_token_created',
1361
- token,
1362
- expiresIn: ttl,
1363
- }));
1364
- }
1365
- async handleRequestPairingToken(ws, state, msg) {
1366
- const logger = getLogger();
1367
- if (!(this.options.pairingEnabled ?? true)) {
1368
- this.sendError(ws, 'Pairing is disabled on this relay');
1369
- return;
1370
- }
1371
- if (!msg.token) {
1372
- this.sendError(ws, 'Token required for pairing request');
1373
- return;
1374
- }
1375
- // If auth backend is configured, delegate
1376
- if (this.options.authBackend) {
1377
- const result = await this.options.authBackend.requestPairingToken(msg.token, state.ip);
1378
- if (!result.ok) {
1379
- this.sendAuthError(ws, result.code, result.message);
1380
- return;
1381
- }
1382
- logger.info('Pairing token created (one-shot, via backend)', { userId: result.userId });
1383
- ws.send(JSON.stringify({
1384
- type: 'pairing_token_created',
1385
- token: result.pairingToken,
1386
- expiresIn: result.expiresIn,
1387
- }));
1388
- return;
1389
- }
1390
- // Legacy: inline pairing token creation
1391
- let authResult = { ok: false, message: 'No auth provider accepted the token' };
1392
- for (const provider of (this.options.authProviders?.values() ?? [])) {
1393
- authResult = await provider.authenticate({ token: msg.token, ip: state.ip });
1394
- if (authResult.ok)
1395
- break;
1396
- }
1397
- if (!authResult.ok && this.options.authProvider && !this.options.authProviders?.size) {
1398
- authResult = await this.options.authProvider.authenticate({ token: msg.token, ip: state.ip });
1399
- }
1400
- if (!authResult.ok) {
1401
- this.sendAuthError(ws, 'auth_rejected', authResult.message);
1402
- return;
1403
- }
1404
- this.storage.upsertUser(authResult.user.id, authResult.user.login, authResult.user.provider, authResult.user.email);
1405
- const token = `pt_${randomBytes(32).toString('hex')}`;
1406
- const ttl = this.options.pairingTtl ?? 300;
1407
- this.pairingTokens.set(token, {
1408
- userId: authResult.user.id,
1409
- expiresAt: Date.now() + ttl * 1000,
1410
- });
1411
- logger.info('Pairing token created (one-shot)', { userId: authResult.user.id, ttl });
1412
- ws.send(JSON.stringify({
1413
- type: 'pairing_token_created',
1414
- token,
1415
- expiresIn: ttl,
1416
- }));
1417
- }
1418
622
  // --- Device presence notifications ---
1419
- handleRemoveDevice(ws, state, targetDeviceId) {
1420
- const logger = getLogger();
1421
- if (!state.userId || !state.deviceId) {
1422
- this.sendError(ws, 'Not authenticated');
1423
- return;
1424
- }
1425
- if (!targetDeviceId || typeof targetDeviceId !== 'string') {
1426
- this.sendError(ws, 'Missing deviceId');
1427
- return;
1428
- }
1429
- const targetDevice = this.storage.getDevice(targetDeviceId);
1430
- if (!targetDevice || targetDevice.userId !== state.userId) {
1431
- this.sendError(ws, 'Device not found');
1432
- return;
1433
- }
1434
- // Only allow removing offline devices (zombie cleanup)
1435
- if (this.connections.has(targetDeviceId)) {
1436
- this.sendError(ws, 'Cannot remove an online device');
1437
- return;
1438
- }
1439
- this.storage.deleteDevice(targetDeviceId);
1440
- this.storage.deletePushTokensForDevice(targetDeviceId);
1441
- // Drop the device's pulse endpoint + persisted outbox/snapshot/capability
1442
- // rows too. Otherwise they outlive the device record forever, and
1443
- // recoverOnBoot faithfully rebuilds an endpoint for a device that can
1444
- // never authenticate again.
1445
- this.pulseHub.forgetDevice(targetDeviceId);
1446
- logger.info('Device removed', { deviceId: targetDeviceId, byDevice: state.deviceId });
1447
- // Edge mode: the account service owns the device list and every auth
1448
- // mirrors it back here, so the removal must reach it too (otherwise the
1449
- // device reappears after the next reconnect).
1450
- const remote = this.options.authBackend;
1451
- if (remote instanceof RemoteAuthBackend) {
1452
- const userId = state.userId;
1453
- remote.removeDevice(userId, targetDeviceId).then((ok) => {
1454
- if (!ok)
1455
- logger.warn('Account service did not remove device', { deviceId: targetDeviceId });
1456
- }).catch((err) => {
1457
- logger.warn('Failed to remove device at account service', { deviceId: targetDeviceId, error: err.message });
1458
- });
1459
- }
1460
- // Broadcast removal to all remaining user devices
1461
- this.broadcastDeviceRemoved(state.userId, targetDeviceId);
1462
- }
623
+ // --- Account deletion ---
624
+ /** Users whose deletion is in flight (a double tap must not run it twice). */
625
+ deletingAccounts = new Set();
1463
626
  // --- Push token management ---
1464
- handleRegisterPushToken(ws, state, msg) {
1465
- const logger = getLogger();
1466
- if (!state.userId || !state.deviceId) {
1467
- this.sendError(ws, 'Not authenticated');
1468
- return;
1469
- }
1470
- const payload = msg.payload;
1471
- if (!payload || typeof payload.token !== 'string' || typeof payload.provider !== 'string') {
1472
- this.sendError(ws, 'Invalid push token: payload.token and payload.provider required');
1473
- return;
1474
- }
1475
- this.storage.upsertPushToken(state.deviceId, payload.provider, payload.token, payload.environment, payload.bundleId);
1476
- // Clean up push tokens from stale devices (offline >24h) to prevent
1477
- // duplicate notifications from abandoned PWA installs on the same phone.
1478
- const onlineDeviceIds = Array.from(this.connections.keys());
1479
- const pruned = this.storage.deleteStaleUserPushTokens(state.userId, state.deviceId, onlineDeviceIds);
1480
- if (pruned > 0) {
1481
- logger.info('Pruned stale push tokens', { userId: state.userId, count: pruned });
1482
- }
1483
- logger.info('Push token registered', { deviceId: state.deviceId, provider: payload.provider });
1484
- if (state.deviceId) {
1485
- this.sendControlToDevice(state.deviceId, { type: 'push_token_registered', payload: { provider: payload.provider } });
1486
- }
1487
- }
1488
- handleUnregisterPushToken(ws, state, msg) {
1489
- const logger = getLogger();
1490
- if (!state.userId || !state.deviceId) {
1491
- this.sendError(ws, 'Not authenticated');
1492
- return;
1493
- }
1494
- const payload = msg.payload;
1495
- if (!payload || typeof payload.provider !== 'string') {
1496
- this.sendError(ws, 'Invalid unregister: payload.provider required');
1497
- return;
1498
- }
1499
- this.storage.deletePushToken(state.deviceId, payload.provider);
1500
- logger.info('Push token unregistered', { deviceId: state.deviceId, provider: payload.provider });
1501
- }
1502
- broadcastDeviceRemoved(userId, removedDeviceId) {
1503
- let userDevices;
1504
- try {
1505
- userDevices = this.storage.getDevicesByUser(userId);
1506
- }
1507
- catch {
1508
- return;
1509
- }
1510
- for (const d of userDevices) {
1511
- const ws = this.connections.get(d.id);
1512
- if (!ws || ws.readyState !== WebSocket.OPEN)
1513
- continue;
1514
- this.sendPresenceToDevice(d.id, { type: 'device_removed', deviceId: removedDeviceId });
1515
- }
1516
- }
1517
- broadcastDeviceJoined(userId, newDeviceId) {
1518
- let device;
1519
- try {
1520
- device = this.storage.getDevice(newDeviceId);
1521
- }
1522
- catch {
1523
- return;
1524
- } // Storage may be closed during shutdown
1525
- if (!device)
1526
- return;
1527
- const summary = {
1528
- id: device.id,
1529
- name: device.name,
1530
- role: device.role,
1531
- kind: device.kind ?? undefined,
1532
- publicKey: device.publicKey ?? undefined,
1533
- encryptionKey: device.encryptionKey ?? undefined,
1534
- online: true,
1535
- lastSeen: device.lastSeen,
1536
- createdAt: device.createdAt,
1537
- };
1538
- let userDevices;
1539
- try {
1540
- userDevices = this.storage.getDevicesByUser(userId);
1541
- }
1542
- catch {
1543
- return;
1544
- }
1545
- for (const d of userDevices) {
1546
- if (d.id === newDeviceId)
1547
- continue;
1548
- const ws = this.connections.get(d.id);
1549
- if (!ws || ws.readyState !== WebSocket.OPEN)
1550
- continue;
1551
- this.sendPresenceToDevice(d.id, { type: 'device_joined', device: summary });
1552
- }
1553
- }
1554
- broadcastDeviceLeft(userId, leftDeviceId) {
1555
- let userDevices;
1556
- try {
1557
- userDevices = this.storage.getDevicesByUser(userId);
1558
- }
1559
- catch {
1560
- return;
1561
- } // Storage may be closed during shutdown
1562
- for (const d of userDevices) {
1563
- if (d.id === leftDeviceId)
1564
- continue;
1565
- const ws = this.connections.get(d.id);
1566
- if (!ws || ws.readyState !== WebSocket.OPEN)
1567
- continue;
1568
- this.sendPresenceToDevice(d.id, { type: 'device_left', deviceId: leftDeviceId });
1569
- }
1570
- }
1571
- broadcastDevicePending(userId, pendingDeviceId) {
1572
- let userDevices;
1573
- try {
1574
- userDevices = this.storage.getDevicesByUser(userId);
1575
- }
1576
- catch {
1577
- return;
1578
- }
1579
- for (const d of userDevices) {
1580
- if (d.id === pendingDeviceId)
1581
- continue;
1582
- const ws = this.connections.get(d.id);
1583
- if (!ws || ws.readyState !== WebSocket.OPEN)
1584
- continue;
1585
- this.sendPresenceToDevice(d.id, { type: 'device_pending', deviceId: pendingDeviceId });
1586
- }
1587
- }
1588
627
  /** Handle pong from either protocol-level or JSON pong.
1589
628
  * Re-promotes to device_joined if we already broadcast device_pending.
1590
629
  * Guards against stale pongs from replaced connections (reconnect race). */
@@ -1606,30 +645,32 @@ export class HeadServer {
1606
645
  const activeWs = this.connections.get(state.deviceId);
1607
646
  const activeState = activeWs ? this.clients.get(activeWs) : null;
1608
647
  if (activeState === state) {
1609
- this.broadcastDeviceJoined(state.userId, state.deviceId);
648
+ broadcastDeviceJoined(this, state.userId, state.deviceId);
1610
649
  }
1611
650
  }
1612
651
  }
1613
652
  // --- Helpers ---
1614
- getDeviceSummaries(userId) {
1615
- const stored = this.storage.getDevicesByUser(userId);
1616
- return stored.map(d => ({
1617
- id: d.id,
1618
- name: d.name,
1619
- role: d.role,
1620
- kind: d.kind ?? undefined,
1621
- publicKey: d.publicKey ?? undefined,
1622
- encryptionKey: d.encryptionKey ?? undefined,
1623
- online: this.connections.has(d.id),
1624
- lastSeen: d.lastSeen,
1625
- createdAt: d.createdAt,
1626
- }));
1627
- }
1628
653
  sendError(ws, message) {
1629
654
  if (ws.readyState === WebSocket.OPEN) {
1630
655
  ws.send(JSON.stringify({ type: 'server_error', message }));
1631
656
  }
1632
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
+ }
1633
674
  getStats() {
1634
675
  const onlineUserIds = new Set();
1635
676
  const connectionList = [];
@@ -1652,8 +693,9 @@ export class HeadServer {
1652
693
  });
1653
694
  }
1654
695
  const allUsers = this.storage.getAllUsers();
696
+ const devicesByUser = this.storage.getAllDevicesByUser();
1655
697
  const registeredUsers = allUsers.map(u => {
1656
- const devices = this.storage.getDevicesByUser(u.userId);
698
+ const devices = devicesByUser.get(u.userId) ?? [];
1657
699
  return {
1658
700
  userId: u.userId,
1659
701
  username: u.username,