@kraki/head 0.19.4 → 0.20.1

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,21 +1,20 @@
1
1
  import { RemoteAuthBackend } from './remote-auth-backend.js';
2
2
  import { WebSocketServer, WebSocket } from 'ws';
3
- import { randomBytes, createVerify } from 'crypto';
4
- import { v4 as uuid } from 'uuid';
5
- import { HEAD_PULSE_TARGET } from '@kraki/protocol';
3
+ import { HEAD_PULSE_TARGET, ACCOUNT_DELETED_CLOSE_CODE, DEVICE_REPLACED_CLOSE_CODE } from '@kraki/protocol';
4
+ import { parseVoiceWordOps } from './voice-vocabulary.js';
6
5
  import { PulseHub, PULSE_ACK_EVERY_BYTES } from './pulse-hub.js';
7
- import { GitHubAuthProvider } from './auth.js';
6
+ import { LocalAuthBackend } from './local-auth-backend.js';
8
7
  import { getLogger } from './logger.js';
9
8
  import { trace, fp } from './trace.js';
10
9
  import { clientIp } from './client-ip.js';
11
- function importPublicKey(compactKey) {
12
- const lines = compactKey.match(/.{1,64}/g) ?? [];
13
- return `-----BEGIN PUBLIC KEY-----\n${lines.join('\n')}\n-----END PUBLIC KEY-----\n`;
14
- }
15
- function verifySignature(nonce, signature, publicKeyPem) {
16
- const verify = createVerify('SHA256');
17
- verify.update(nonce);
18
- return verify.verify(publicKeyPem, signature, 'base64');
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;
19
18
  }
20
19
  const DEFAULT_VOICE_LEASE_TTL_SEC = 86_400;
21
20
  const DEFAULT_VOICE_DAILY_QUOTA_SEC = 7_200;
@@ -25,6 +24,8 @@ const DEFAULT_MAX_PAYLOAD = 10 * 1024 * 1024;
25
24
  const DEFAULT_AUTH_TIMEOUT_MS = 15_000;
26
25
  const DEFAULT_LOGIN_SCREEN_AUTH_TIMEOUT_MS = 10 * 60_000;
27
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;
28
29
  /** How often the liveness sweep runs (ms). Faster than the 30s ping timer so
29
30
  * device_pending detection latency tracks the recipient's ping cadence
30
31
  * (~10s), not the slower liveness check cadence. */
@@ -60,7 +61,7 @@ export class HeadServer {
60
61
  static PING_INTERVAL = 30_000;
61
62
  // In-memory state
62
63
  connections = new Map();
63
- pairingTokens = new Map();
64
+ authBackend;
64
65
  userByDevice = new Map();
65
66
  clients = new Map();
66
67
  pingTimer = null;
@@ -71,6 +72,21 @@ export class HeadServer {
71
72
  constructor(storage, options) {
72
73
  this.storage = storage;
73
74
  this.options = options;
75
+ // One auth implementation for every deployment: without an explicit
76
+ // backend (standalone tests, self-hosted defaults) the server builds the
77
+ // same LocalAuthBackend production uses from its providers.
78
+ this.authBackend = options.authBackend ?? new LocalAuthBackend({
79
+ storage,
80
+ // A single `authProvider` answers every token method, as it always has.
81
+ authProviders: options.authProviders
82
+ ?? (options.authProvider
83
+ ? new Map(['open', 'apikey', 'github'].map((method) => [method, options.authProvider]))
84
+ : new Map()),
85
+ pairingEnabled: options.pairingEnabled,
86
+ pairingTtl: options.pairingTtl,
87
+ pushManager: options.pushManager,
88
+ region: options.region,
89
+ });
74
90
  this.wss = new WebSocketServer({
75
91
  noServer: true,
76
92
  maxPayload: options.maxPayload ?? DEFAULT_MAX_PAYLOAD,
@@ -144,10 +160,7 @@ export class HeadServer {
144
160
  }
145
161
  }
146
162
  // Sweep expired pairing tokens
147
- for (const [token, data] of this.pairingTokens) {
148
- if (now > data.expiresAt)
149
- this.pairingTokens.delete(token);
150
- }
163
+ this.authBackend.sweepPairingTokens?.();
151
164
  // Periodic health snapshot — ALWAYS on (not trace-gated). Cheap (one line
152
165
  // / 30s) and is the canary for the OOM / event-loop-stall class of bugs
153
166
  // that recurred across the pulse PRs. rss/heap from process.memoryUsage;
@@ -216,22 +229,6 @@ export class HeadServer {
216
229
  }
217
230
  // ── End liveness sweep ──────────────────────────────────
218
231
  // --- Auth provider helpers ---
219
- getAuthProvider() {
220
- if (this.options.authProviders?.size) {
221
- return this.options.authProviders.values().next().value;
222
- }
223
- return this.options.authProvider;
224
- }
225
- getAuthProviderForMode(mode) {
226
- if (mode && this.options.authProviders?.has(mode)) {
227
- return this.options.authProviders.get(mode);
228
- }
229
- return this.getAuthProvider();
230
- }
231
- getGitHubClientId() {
232
- const ghProvider = this.findGitHubProvider();
233
- return ghProvider?.oauthConfigured ? ghProvider.getClientId() : undefined;
234
- }
235
232
  getVapidPublicKey() {
236
233
  return this.options.pushManager?.getVapidPublicKey();
237
234
  }
@@ -251,19 +248,6 @@ export class HeadServer {
251
248
  return undefined;
252
249
  return { brokerUrl: url, resource: 'voice/doubao' };
253
250
  }
254
- findGitHubProvider() {
255
- const provider = this.options.authProviders?.get('github') ?? this.options.authProvider;
256
- if (!provider)
257
- return undefined;
258
- if (provider instanceof GitHubAuthProvider)
259
- return provider;
260
- if ('inner' in provider) {
261
- const inner = provider.inner;
262
- if (inner instanceof GitHubAuthProvider)
263
- return inner;
264
- }
265
- return undefined;
266
- }
267
251
  // --- Connection management ---
268
252
  attach(server) {
269
253
  server.on('upgrade', (req, socket, head) => {
@@ -303,6 +287,11 @@ export class HeadServer {
303
287
  logger.debug('WebSocket connected', { ip });
304
288
  ws.on('pong', () => { this.onPongReceived(state); });
305
289
  ws.on('message', (data) => {
290
+ // An evicted socket stays open until its close handshake completes;
291
+ // nothing it sends may act for the device (its Pulse stream now
292
+ // belongs to the replacement connection).
293
+ if (state.evicted)
294
+ return;
306
295
  state.lastInboundAt = Date.now();
307
296
  try {
308
297
  const msg = JSON.parse(data.toString());
@@ -379,37 +368,13 @@ export class HeadServer {
379
368
  if (!state.authenticated) {
380
369
  if (msg.type === 'auth_info') {
381
370
  this.armAuthTimer(ws, state, this.options.loginScreenAuthTimeoutMs ?? DEFAULT_LOGIN_SCREEN_AUTH_TIMEOUT_MS);
382
- if (this.options.authBackend) {
383
- const info = this.options.authBackend.getAuthInfo();
384
- ws.send(JSON.stringify({
385
- type: 'auth_info_response',
386
- methods: info.methods,
387
- githubClientId: info.githubClientId,
388
- vapidPublicKey: info.vapidPublicKey ?? this.getVapidPublicKey(),
389
- }));
390
- }
391
- else {
392
- const methods = [];
393
- if (this.options.authProviders?.has('github')) {
394
- methods.push('github_token');
395
- const ghProvider = this.findGitHubProvider();
396
- if (ghProvider?.oauthConfigured)
397
- methods.push('github_oauth');
398
- }
399
- if (this.options.authProviders?.has('apikey'))
400
- methods.push('apikey');
401
- if (this.options.authProviders?.has('open'))
402
- methods.push('open');
403
- if (this.options.pairingEnabled !== false)
404
- methods.push('pairing');
405
- methods.push('challenge');
406
- ws.send(JSON.stringify({
407
- type: 'auth_info_response',
408
- methods,
409
- githubClientId: this.getGitHubClientId(),
410
- vapidPublicKey: this.getVapidPublicKey(),
411
- }));
412
- }
371
+ const info = this.authBackend.getAuthInfo();
372
+ ws.send(JSON.stringify({
373
+ type: 'auth_info_response',
374
+ methods: info.methods,
375
+ githubClientId: info.githubClientId,
376
+ vapidPublicKey: info.vapidPublicKey ?? this.getVapidPublicKey(),
377
+ }));
413
378
  return;
414
379
  }
415
380
  if (msg.type === 'auth') {
@@ -429,7 +394,7 @@ export class HeadServer {
429
394
  }
430
395
  // Authenticated messages
431
396
  if (msg.type === 'create_pairing_token') {
432
- this.handleCreatePairingToken(ws, state);
397
+ await this.handleCreatePairingToken(ws, state);
433
398
  return;
434
399
  }
435
400
  if (msg.type === 'unicast') {
@@ -455,7 +420,10 @@ export class HeadServer {
455
420
  // endpoint/disk-growth amplifier from a single client.
456
421
  if (to !== '' && to !== HEAD_PULSE_TARGET) {
457
422
  const target = this.storage.getDevice(to);
458
- if (!target || target.userId !== state.userId) {
423
+ // Same account, and only across roles (app ⇄ tentacle): no feature
424
+ // sends app→app or tentacle→tentacle, so such a frame is not trusted.
425
+ const sender = this.storage.getDevice(state.deviceId);
426
+ if (!target || target.userId !== state.userId || !sender || target.role === sender.role) {
459
427
  this.sendError(ws, 'unicast target is not reachable');
460
428
  return;
461
429
  }
@@ -516,8 +484,10 @@ export class HeadServer {
516
484
  if (typeof msg.pulse === 'string' && state.deviceId) {
517
485
  // A pushPreview may ride the same pulse envelope — fire it for offline
518
486
  // devices (the reliable message itself is delivered by the hub).
519
- if (msg.pushPreview)
487
+ // Push previews are tentacle-originated (same rule as dispatch_push).
488
+ if (msg.pushPreview && this.storage.getDevice(state.deviceId)?.role === 'tentacle') {
520
489
  this.firePushPreview(state, msg.pushPreview);
490
+ }
521
491
  this.pulseHub.onPulseEnvelope(state.deviceId, { pulse: msg.pulse });
522
492
  return;
523
493
  }
@@ -660,6 +630,14 @@ export class HeadServer {
660
630
  trace('HUB-TX-WS', { to: deviceId, online, pulseB64Len: pulseB64.length });
661
631
  if (!online)
662
632
  return false;
633
+ // Backpressure: a connected but stalled client would otherwise grow the
634
+ // socket's send buffer without bound. Drop the connection instead; every
635
+ // reliable frame is still in the pulse outbox and resends on reconnect.
636
+ if (ws.bufferedAmount > MAX_WS_BUFFERED_BYTES) {
637
+ getLogger().warn('Closing stalled connection (send buffer full)', { deviceId, bufferedBytes: ws.bufferedAmount });
638
+ ws.terminate();
639
+ return false;
640
+ }
663
641
  // `to` lets the receiver's own pulse layer know which stream this is; blob
664
642
  // is empty because the payload (if any) is inside the pulse frame.
665
643
  ws.send(JSON.stringify({ type: 'unicast', to: deviceId, pulse: pulseB64, blob: '', keys: {} }));
@@ -716,9 +694,15 @@ export class HeadServer {
716
694
  case 'update_preferences':
717
695
  this.handleUpdatePreferences(ws, state, msg);
718
696
  return true;
697
+ case 'update_voice_vocabulary':
698
+ this.handleUpdateVoiceVocabulary(state, msg);
699
+ return true;
719
700
  case 'remove_device':
720
701
  this.handleRemoveDevice(ws, state, msg.deviceId);
721
702
  return true;
703
+ case 'delete_account':
704
+ void this.handleDeleteAccount(ws, state);
705
+ return true;
722
706
  case 'register_push_token':
723
707
  this.handleRegisterPushToken(ws, state, msg);
724
708
  return true;
@@ -747,7 +731,7 @@ export class HeadServer {
747
731
  this.storage.updatePreferences(state.userId, prefs);
748
732
  // Read back the full merged preferences
749
733
  const fullUser = this.storage.getUser(state.userId);
750
- const merged = fullUser?.preferences ?? prefs;
734
+ const merged = publicPreferences(fullUser?.preferences ?? prefs);
751
735
  const confirmation = { type: 'preferences_updated', preferences: merged };
752
736
  // Confirm to the sender + fan out to the user's other devices — all over pulse.
753
737
  if (state.deviceId)
@@ -769,6 +753,35 @@ export class HeadServer {
769
753
  this.sendControlToDevice(d.id, confirmation);
770
754
  }
771
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
+ }
784
+ }
772
785
  /** Fan-out targets for a pulse broadcast from `fromDevice`: all OTHER
773
786
  * ONLINE devices of the same user. Offline devices are excluded because
774
787
  * broadcasts (user_message / agent_message / delta / active / idle /
@@ -882,11 +895,21 @@ export class HeadServer {
882
895
  state.pulseProgressAck = msg.device?.pulseProgressAck === true;
883
896
  const logger = getLogger();
884
897
  const { auth } = msg;
885
- // If auth backend is configured, delegate everything to it
886
- if (this.options.authBackend) {
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 {
887
908
  if (auth.method === 'challenge') {
888
909
  // Challenge is multi-step — start here, verify in handleChallengeResponse
889
- const result = await this.options.authBackend.startChallenge(auth.deviceId, msg.device.encryptionKey, this.options.region);
910
+ const result = await this.authBackend.startChallenge(auth.deviceId, msg.device.encryptionKey, this.options.region);
911
+ if (!this.socketStillAuthenticating(ws, state))
912
+ return;
890
913
  if (!result.ok) {
891
914
  this.sendAuthError(ws, result.code, result.message, result.code === 'wrong_region' ? result.redirect : undefined);
892
915
  return;
@@ -895,118 +918,24 @@ export class HeadServer {
895
918
  state.pendingDeviceId = result.deviceId;
896
919
  state.pendingDeviceInfo = { encryptionKey: msg.device.encryptionKey };
897
920
  state.pendingAuthMethod = 'challenge';
898
- logger.debug('Issuing auth challenge (via backend)', { deviceId: auth.deviceId });
921
+ logger.debug('Issuing auth challenge', { deviceId: auth.deviceId });
899
922
  ws.send(JSON.stringify({ type: 'auth_challenge', nonce: result.nonce }));
900
923
  return;
901
924
  }
902
- const result = await this.options.authBackend.authenticate(auth, msg.device, this.options.region);
925
+ const result = await this.authBackend.authenticate(auth, msg.device, this.options.region, state.ip);
926
+ if (!this.socketStillAuthenticating(ws, state))
927
+ return;
903
928
  this.handleBackendAuthResult(ws, state, result);
904
- return;
905
929
  }
906
- // Legacy: inline auth (no backend configured)
907
- switch (auth.method) {
908
- case 'pairing': {
909
- this.handlePairingAuth(ws, state, auth.token, msg);
910
- return;
911
- }
912
- case 'challenge': {
913
- const device = this.storage.getDevice(auth.deviceId);
914
- if (device && device.publicKey) {
915
- const nonce = randomBytes(32).toString('hex');
916
- state.pendingNonce = nonce;
917
- state.pendingDeviceId = auth.deviceId;
918
- state.pendingDeviceInfo = { encryptionKey: msg.device.encryptionKey };
919
- state.pendingAuthMethod = 'challenge';
920
- logger.debug('Issuing auth challenge', { deviceId: auth.deviceId });
921
- ws.send(JSON.stringify({ type: 'auth_challenge', nonce }));
922
- return;
923
- }
924
- this.sendAuthError(ws, 'unknown_device', 'Unknown device');
925
- return;
926
- }
927
- case 'github_token': {
928
- const provider = this.getAuthProviderForMode('github');
929
- const result = await provider.authenticate({ token: auth.token, ip: state.ip });
930
- if (!result.ok) {
931
- logger.warn('Auth rejected', { method: 'github_token', ip: state.ip, reason: result.message });
932
- // A GitHub outage or network error is not a bad credential (G4).
933
- this.sendAuthError(ws, result.retryable ? 'service_unavailable' : 'auth_rejected', result.message);
934
- return;
935
- }
936
- this.completeAuth(ws, state, result.user, msg, 'github_token');
937
- return;
938
- }
939
- case 'github_oauth': {
940
- const provider = this.getAuthProviderForMode('github');
941
- const oauthAuth = auth;
942
- const result = await provider.authenticate({
943
- githubCode: oauthAuth.code,
944
- codeVerifier: typeof oauthAuth.codeVerifier === 'string' ? oauthAuth.codeVerifier : undefined,
945
- redirectUri: typeof oauthAuth.redirectUri === 'string' ? oauthAuth.redirectUri : undefined,
946
- ip: state.ip,
947
- });
948
- if (!result.ok) {
949
- logger.warn('Auth rejected', { method: 'github_oauth', ip: state.ip, reason: result.message });
950
- // A GitHub outage or network error is not a bad credential (G4).
951
- this.sendAuthError(ws, result.retryable ? 'service_unavailable' : 'auth_rejected', result.message);
952
- return;
953
- }
954
- this.completeAuth(ws, state, result.user, msg, 'github_oauth');
955
- return;
956
- }
957
- case 'apikey': {
958
- const provider = this.getAuthProviderForMode('apikey');
959
- const result = await provider.authenticate({ token: auth.key, ip: state.ip });
960
- if (!result.ok) {
961
- logger.warn('Auth rejected', { method: 'apikey', ip: state.ip, reason: result.message });
962
- // A GitHub outage or network error is not a bad credential (G4).
963
- this.sendAuthError(ws, result.retryable ? 'service_unavailable' : 'auth_rejected', result.message);
964
- return;
965
- }
966
- this.completeAuth(ws, state, result.user, msg, 'apikey');
967
- return;
968
- }
969
- case 'open': {
970
- const provider = this.getAuthProviderForMode('open');
971
- const result = await provider.authenticate({ token: auth.sharedKey, ip: state.ip });
972
- if (!result.ok) {
973
- logger.warn('Auth rejected', { method: 'open', 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, 'open');
979
- return;
980
- }
981
- default: {
982
- const method = auth.method;
983
- this.sendAuthError(ws, 'unknown_auth_method', `Unknown auth method: ${method}`);
984
- }
930
+ finally {
931
+ state.authInFlight = false;
985
932
  }
986
933
  }
987
- handlePairingAuth(ws, state, pairingToken, msg) {
988
- const logger = getLogger();
989
- if (!(this.options.pairingEnabled ?? true)) {
990
- this.sendAuthError(ws, 'pairing_disabled', 'Pairing is disabled.');
991
- return;
992
- }
993
- const tokenData = this.pairingTokens.get(pairingToken);
994
- if (!tokenData || tokenData.expiresAt < Date.now()) {
995
- if (tokenData)
996
- this.pairingTokens.delete(pairingToken);
997
- logger.warn('Pairing auth failed: invalid or expired token', { ip: state.ip });
998
- this.sendAuthError(ws, 'invalid_pairing_token', 'Invalid or expired pairing token');
999
- return;
1000
- }
1001
- // Consume token (single-use)
1002
- this.pairingTokens.delete(pairingToken);
1003
- const user = this.storage.getUser(tokenData.userId);
1004
- if (!user) {
1005
- this.sendAuthError(ws, 'user_not_found', 'User not found');
1006
- return;
1007
- }
1008
- const authUser = { id: user.userId, login: user.username, provider: user.provider, email: user.email };
1009
- this.completeAuth(ws, state, authUser, msg, 'pairing');
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;
1010
939
  }
1011
940
  handleChallengeResponse(ws, state, msg) {
1012
941
  const logger = getLogger();
@@ -1020,68 +949,25 @@ export class HeadServer {
1020
949
  this.sendAuthError(ws, 'no_pending_challenge', 'No pending challenge');
1021
950
  return;
1022
951
  }
1023
- // If auth backend is configured, delegate verification
1024
- if (this.options.authBackend) {
1025
- this.options.authBackend.verifyChallenge(deviceId, nonce, msg.signature, pendingInfo?.encryptionKey, this.options.region).then(result => {
1026
- this.handleBackendAuthResult(ws, state, result);
1027
- }).catch(err => {
1028
- // A thrown error means the auth backend itself was unreachable / timed
1029
- // out (network, 5xx, abort) — NOT that the signature was invalid. A
1030
- // deterministic auth failure comes back as a resolved AuthError outcome
1031
- // above. Reporting this as auth_rejected made the arm treat a transient
1032
- // backend outage as a permanent credential failure and wipe the paired
1033
- // device, forcing a full re-pair. Surface it as auth_unavailable so the
1034
- // client retries instead of destroying a valid session.
1035
- logger.error('Auth backend verifyChallenge failed', { error: err.message });
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)) {
1036
967
  this.sendAuthError(ws, 'service_unavailable', 'Authentication service unavailable');
1037
- });
1038
- return;
1039
- }
1040
- // Legacy: inline challenge verification
1041
- const device = this.storage.getDevice(deviceId);
1042
- if (!device || !device.publicKey) {
1043
- this.sendAuthError(ws, 'device_not_found', 'Device not found');
1044
- return;
1045
- }
1046
- const publicKeyPem = importPublicKey(device.publicKey);
1047
- const valid = verifySignature(nonce, msg.signature, publicKeyPem);
1048
- if (!valid) {
1049
- logger.warn('Challenge-response auth failed', { deviceId, ip: state.ip });
1050
- this.sendAuthError(ws, 'invalid_signature', 'Invalid signature');
1051
- return;
1052
- }
1053
- // Update encryption key if provided
1054
- const encryptionKey = pendingInfo?.encryptionKey ?? device.encryptionKey ?? undefined;
1055
- this.storage.upsertDevice(deviceId, device.userId, device.name, device.role, device.kind ?? undefined, device.publicKey ?? undefined, encryptionKey);
1056
- const user = this.storage.getUser(device.userId);
1057
- if (!user) {
1058
- this.sendAuthError(ws, 'user_not_found', 'User not found');
1059
- return;
1060
- }
1061
- // Register connection
1062
- state.authenticated = true;
1063
- state.authenticatedAt = Date.now();
1064
- if (state.authTimer) {
1065
- clearTimeout(state.authTimer);
1066
- state.authTimer = undefined;
1067
- }
1068
- state.deviceId = deviceId;
1069
- state.userId = user.userId;
1070
- this.evictPreviousConnection(deviceId, ws);
1071
- this.connections.set(deviceId, ws);
1072
- this.userByDevice.set(deviceId, user.userId);
1073
- trace('DEV-CONNECT', { device: deviceId, user: user.userId, auth: 'challenge' });
1074
- logger.info('Device authenticated via challenge-response', { deviceId, ip: state.ip });
1075
- this.completeAuthHandshake(ws, {
1076
- deviceId,
1077
- userId: user.userId,
1078
- user: {
1079
- id: user.userId,
1080
- login: user.username,
1081
- provider: user.provider,
1082
- email: user.email,
1083
- },
1084
- authMethod: 'challenge',
968
+ }
969
+ }).finally(() => {
970
+ state.authInFlight = false;
1085
971
  });
1086
972
  }
1087
973
  /**
@@ -1174,23 +1060,27 @@ export class HeadServer {
1174
1060
  const fullUser = this.storage.getUser(params.userId);
1175
1061
  const userResponse = {
1176
1062
  ...params.user,
1177
- preferences: fullUser?.preferences ?? params.user.preferences,
1063
+ preferences: publicPreferences(fullUser?.preferences ?? params.user.preferences),
1178
1064
  };
1179
1065
  // Devices list. If the caller provided one (edge mode), recompute online
1180
1066
  // status against our current connections map. Otherwise derive locally.
1181
1067
  const devices = params.devices
1182
1068
  ? params.devices.map(d => ({ ...d, online: this.connections.has(d.id) }))
1183
1069
  : this.getDeviceSummaries(params.userId);
1070
+ const deviceRole = devices.find((device) => device.id === params.deviceId)?.role
1071
+ ?? this.storage.getDevice(params.deviceId)?.role;
1184
1072
  ws.send(JSON.stringify({
1185
1073
  type: 'auth_ok',
1186
1074
  deviceId: params.deviceId,
1187
1075
  authMethod: params.authMethod,
1188
1076
  user: userResponse,
1189
1077
  devices,
1190
- githubClientId: params.githubClientId ?? this.getGitHubClientId(),
1078
+ githubClientId: params.githubClientId ?? this.authBackend.getAuthInfo().githubClientId,
1191
1079
  vapidPublicKey: params.vapidPublicKey ?? this.getVapidPublicKey(),
1192
1080
  relayVersion: this.options.version,
1193
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) }),
1194
1084
  ...(this.getVoiceCapability() && { voice: this.getVoiceCapability() }),
1195
1085
  }));
1196
1086
  // Clients only accept Pulse envelopes after processing auth_ok. Starting
@@ -1204,8 +1094,6 @@ export class HeadServer {
1204
1094
  // process epoch, fence the preceding process's non-durable downlink so the
1205
1095
  // new UI cannot animate through old live events. Tentacle downlinks carry
1206
1096
  // commands and retain ordinary Pulse resume semantics across process epochs.
1207
- const deviceRole = devices.find((device) => device.id === params.deviceId)?.role
1208
- ?? this.storage.getDevice(params.deviceId)?.role;
1209
1097
  this.pulseHub.onDeviceConnected(params.deviceId, {
1210
1098
  discardPreviousProcessNonDurable: deviceRole === 'app',
1211
1099
  });
@@ -1231,59 +1119,26 @@ export class HeadServer {
1231
1119
  // its guarded cleanup. Publish the Pulse disconnect here so capability and
1232
1120
  // connection-scoped state cannot leak across the replacement boundary.
1233
1121
  this.pulseHub.onDeviceDisconnected(deviceId);
1122
+ const previousState = this.clients.get(previous);
1123
+ if (previousState)
1124
+ previousState.evicted = true;
1234
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.
1235
1129
  try {
1236
- previous.terminate();
1130
+ previous.close(DEVICE_REPLACED_CLOSE_CODE, 'replaced');
1237
1131
  }
1238
1132
  catch { /* best effort */ }
1239
- }
1240
- completeAuth(ws, state, user, msg, authMethod) {
1241
- const logger = getLogger();
1242
- // Persist user
1243
- this.storage.upsertUser(user.id, user.login, user.provider, user.email);
1244
- // Register device
1245
- const deviceId = msg.device.deviceId ?? `dev_${uuid().slice(0, 12)}`;
1246
- try {
1247
- this.storage.upsertDevice(deviceId, user.id, msg.device.name, msg.device.role, msg.device.kind, msg.device.publicKey, msg.device.encryptionKey);
1248
- }
1249
- catch (err) {
1250
- logger.warn('Device registration failed', { ip: state.ip, error: err.message });
1251
- this.sendAuthError(ws, 'device_registration_failed', err.message);
1252
- return;
1253
- }
1254
- state.authenticated = true;
1255
- state.authenticatedAt = Date.now();
1256
- if (state.authTimer) {
1257
- clearTimeout(state.authTimer);
1258
- state.authTimer = undefined;
1133
+ const force = setTimeout(() => { try {
1134
+ previous.terminate();
1259
1135
  }
1260
- state.deviceId = deviceId;
1261
- state.userId = user.id;
1262
- this.evictPreviousConnection(deviceId, ws);
1263
- this.connections.set(deviceId, ws);
1264
- this.userByDevice.set(deviceId, user.id);
1265
- trace('DEV-CONNECT', { device: deviceId, user: user.id, auth: 'inline' });
1266
- logger.info('Device authenticated', {
1267
- deviceId,
1268
- name: msg.device.name,
1269
- role: msg.device.role,
1270
- user: user.login,
1271
- ip: state.ip,
1272
- });
1273
- this.completeAuthHandshake(ws, {
1274
- deviceId,
1275
- userId: user.id,
1276
- user: {
1277
- id: user.id,
1278
- login: user.login,
1279
- provider: user.provider,
1280
- email: user.email,
1281
- },
1282
- authMethod,
1283
- });
1136
+ catch { /* already gone */ } }, 2_000);
1137
+ force.unref?.();
1138
+ previous.once('close', () => clearTimeout(force));
1284
1139
  }
1285
1140
  // --- Pairing tokens (in-memory) ---
1286
- handleCreatePairingToken(ws, state) {
1141
+ async handleCreatePairingToken(ws, state) {
1287
1142
  const logger = getLogger();
1288
1143
  if (!(this.options.pairingEnabled ?? true)) {
1289
1144
  this.sendError(ws, 'Pairing is disabled on this relay');
@@ -1293,30 +1148,23 @@ export class HeadServer {
1293
1148
  this.sendError(ws, 'Not authenticated');
1294
1149
  return;
1295
1150
  }
1296
- if (this.options.authBackend) {
1297
- try {
1298
- const result = this.options.authBackend.createPairingToken(state.userId);
1299
- logger.info('Pairing token created (via backend)', { userId: state.userId });
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) {
1300
1161
  ws.send(JSON.stringify({ type: 'pairing_token_created', token: result.token, expiresIn: result.expiresIn }));
1301
1162
  }
1302
- catch {
1303
- // Remote backend needs async — fall through to error
1304
- this.sendError(ws, 'Pairing not available in remote mode');
1305
- }
1306
- return;
1307
1163
  }
1308
- const token = `pt_${randomBytes(32).toString('hex')}`;
1309
- const ttl = this.options.pairingTtl ?? 300;
1310
- this.pairingTokens.set(token, {
1311
- userId: state.userId,
1312
- expiresAt: Date.now() + ttl * 1000,
1313
- });
1314
- logger.info('Pairing token created', { userId: state.userId, ttl });
1315
- ws.send(JSON.stringify({
1316
- type: 'pairing_token_created',
1317
- token,
1318
- expiresIn: ttl,
1319
- }));
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
+ }
1320
1168
  }
1321
1169
  async handleRequestPairingToken(ws, state, msg) {
1322
1170
  const logger = getLogger();
@@ -1328,47 +1176,18 @@ export class HeadServer {
1328
1176
  this.sendError(ws, 'Token required for pairing request');
1329
1177
  return;
1330
1178
  }
1331
- // If auth backend is configured, delegate
1332
- if (this.options.authBackend) {
1333
- const result = await this.options.authBackend.requestPairingToken(msg.token, state.ip);
1334
- if (!result.ok) {
1335
- this.sendAuthError(ws, result.code, result.message);
1336
- return;
1337
- }
1338
- logger.info('Pairing token created (one-shot, via backend)', { userId: result.userId });
1339
- ws.send(JSON.stringify({
1340
- type: 'pairing_token_created',
1341
- token: result.pairingToken,
1342
- expiresIn: result.expiresIn,
1343
- }));
1179
+ const result = await this.authBackend.requestPairingToken(msg.token, state.ip);
1180
+ if (ws.readyState !== WebSocket.OPEN)
1344
1181
  return;
1345
- }
1346
- // Legacy: inline pairing token creation
1347
- let authResult = { ok: false, message: 'No auth provider accepted the token' };
1348
- for (const provider of (this.options.authProviders?.values() ?? [])) {
1349
- authResult = await provider.authenticate({ token: msg.token, ip: state.ip });
1350
- if (authResult.ok)
1351
- break;
1352
- }
1353
- if (!authResult.ok && this.options.authProvider && !this.options.authProviders?.size) {
1354
- authResult = await this.options.authProvider.authenticate({ token: msg.token, ip: state.ip });
1355
- }
1356
- if (!authResult.ok) {
1357
- this.sendAuthError(ws, 'auth_rejected', authResult.message);
1182
+ if (!result.ok) {
1183
+ this.sendAuthError(ws, result.code, result.message);
1358
1184
  return;
1359
1185
  }
1360
- this.storage.upsertUser(authResult.user.id, authResult.user.login, authResult.user.provider, authResult.user.email);
1361
- const token = `pt_${randomBytes(32).toString('hex')}`;
1362
- const ttl = this.options.pairingTtl ?? 300;
1363
- this.pairingTokens.set(token, {
1364
- userId: authResult.user.id,
1365
- expiresAt: Date.now() + ttl * 1000,
1366
- });
1367
- logger.info('Pairing token created (one-shot)', { userId: authResult.user.id, ttl });
1186
+ logger.info('Pairing token created (one-shot)', { userId: result.userId });
1368
1187
  ws.send(JSON.stringify({
1369
1188
  type: 'pairing_token_created',
1370
- token,
1371
- expiresIn: ttl,
1189
+ token: result.pairingToken,
1190
+ expiresIn: result.expiresIn,
1372
1191
  }));
1373
1192
  }
1374
1193
  // --- Device presence notifications ---
@@ -1403,7 +1222,7 @@ export class HeadServer {
1403
1222
  // Edge mode: the account service owns the device list and every auth
1404
1223
  // mirrors it back here, so the removal must reach it too (otherwise the
1405
1224
  // device reappears after the next reconnect).
1406
- const remote = this.options.authBackend;
1225
+ const remote = this.authBackend;
1407
1226
  if (remote instanceof RemoteAuthBackend) {
1408
1227
  const userId = state.userId;
1409
1228
  remote.removeDevice(userId, targetDeviceId).then((ok) => {
@@ -1416,6 +1235,65 @@ export class HeadServer {
1416
1235
  // Broadcast removal to all remaining user devices
1417
1236
  this.broadcastDeviceRemoved(state.userId, targetDeviceId);
1418
1237
  }
1238
+ // --- Account deletion ---
1239
+ /** Users whose deletion is in flight (a double tap must not run it twice). */
1240
+ 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
+ }
1419
1297
  // --- Push token management ---
1420
1298
  handleRegisterPushToken(ws, state, msg) {
1421
1299
  const logger = getLogger();
@@ -1431,7 +1309,11 @@ export class HeadServer {
1431
1309
  this.storage.upsertPushToken(state.deviceId, payload.provider, payload.token, payload.environment, payload.bundleId);
1432
1310
  // Clean up push tokens from stale devices (offline >24h) to prevent
1433
1311
  // duplicate notifications from abandoned PWA installs on the same phone.
1434
- const onlineDeviceIds = Array.from(this.connections.keys());
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));
1435
1317
  const pruned = this.storage.deleteStaleUserPushTokens(state.userId, state.deviceId, onlineDeviceIds);
1436
1318
  if (pruned > 0) {
1437
1319
  logger.info('Pruned stale push tokens', { userId: state.userId, count: pruned });
@@ -1608,8 +1490,9 @@ export class HeadServer {
1608
1490
  });
1609
1491
  }
1610
1492
  const allUsers = this.storage.getAllUsers();
1493
+ const devicesByUser = this.storage.getAllDevicesByUser();
1611
1494
  const registeredUsers = allUsers.map(u => {
1612
- const devices = this.storage.getDevicesByUser(u.userId);
1495
+ const devices = devicesByUser.get(u.userId) ?? [];
1613
1496
  return {
1614
1497
  userId: u.userId,
1615
1498
  username: u.username,