@kraki/head 0.20.0 → 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,11 +1,9 @@
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';
6
4
  import { parseVoiceWordOps } from './voice-vocabulary.js';
7
5
  import { PulseHub, PULSE_ACK_EVERY_BYTES } from './pulse-hub.js';
8
- import { GitHubAuthProvider } from './auth.js';
6
+ import { LocalAuthBackend } from './local-auth-backend.js';
9
7
  import { getLogger } from './logger.js';
10
8
  import { trace, fp } from './trace.js';
11
9
  import { clientIp } from './client-ip.js';
@@ -18,15 +16,6 @@ function publicPreferences(prefs) {
18
16
  const { voiceVocabulary: _omitted, ...rest } = prefs;
19
17
  return rest;
20
18
  }
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
19
  const DEFAULT_VOICE_LEASE_TTL_SEC = 86_400;
31
20
  const DEFAULT_VOICE_DAILY_QUOTA_SEC = 7_200;
32
21
  const MIN_VOICE_LEASE_GRANT_SEC = 1;
@@ -35,6 +24,8 @@ const DEFAULT_MAX_PAYLOAD = 10 * 1024 * 1024;
35
24
  const DEFAULT_AUTH_TIMEOUT_MS = 15_000;
36
25
  const DEFAULT_LOGIN_SCREEN_AUTH_TIMEOUT_MS = 10 * 60_000;
37
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;
38
29
  /** How often the liveness sweep runs (ms). Faster than the 30s ping timer so
39
30
  * device_pending detection latency tracks the recipient's ping cadence
40
31
  * (~10s), not the slower liveness check cadence. */
@@ -70,7 +61,7 @@ export class HeadServer {
70
61
  static PING_INTERVAL = 30_000;
71
62
  // In-memory state
72
63
  connections = new Map();
73
- pairingTokens = new Map();
64
+ authBackend;
74
65
  userByDevice = new Map();
75
66
  clients = new Map();
76
67
  pingTimer = null;
@@ -81,6 +72,21 @@ export class HeadServer {
81
72
  constructor(storage, options) {
82
73
  this.storage = storage;
83
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
+ });
84
90
  this.wss = new WebSocketServer({
85
91
  noServer: true,
86
92
  maxPayload: options.maxPayload ?? DEFAULT_MAX_PAYLOAD,
@@ -154,10 +160,7 @@ export class HeadServer {
154
160
  }
155
161
  }
156
162
  // Sweep expired pairing tokens
157
- for (const [token, data] of this.pairingTokens) {
158
- if (now > data.expiresAt)
159
- this.pairingTokens.delete(token);
160
- }
163
+ this.authBackend.sweepPairingTokens?.();
161
164
  // Periodic health snapshot — ALWAYS on (not trace-gated). Cheap (one line
162
165
  // / 30s) and is the canary for the OOM / event-loop-stall class of bugs
163
166
  // that recurred across the pulse PRs. rss/heap from process.memoryUsage;
@@ -226,22 +229,6 @@ export class HeadServer {
226
229
  }
227
230
  // ── End liveness sweep ──────────────────────────────────
228
231
  // --- 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
232
  getVapidPublicKey() {
246
233
  return this.options.pushManager?.getVapidPublicKey();
247
234
  }
@@ -261,19 +248,6 @@ export class HeadServer {
261
248
  return undefined;
262
249
  return { brokerUrl: url, resource: 'voice/doubao' };
263
250
  }
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
251
  // --- Connection management ---
278
252
  attach(server) {
279
253
  server.on('upgrade', (req, socket, head) => {
@@ -313,6 +287,11 @@ export class HeadServer {
313
287
  logger.debug('WebSocket connected', { ip });
314
288
  ws.on('pong', () => { this.onPongReceived(state); });
315
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;
316
295
  state.lastInboundAt = Date.now();
317
296
  try {
318
297
  const msg = JSON.parse(data.toString());
@@ -389,37 +368,13 @@ export class HeadServer {
389
368
  if (!state.authenticated) {
390
369
  if (msg.type === 'auth_info') {
391
370
  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
- }
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
+ }));
423
378
  return;
424
379
  }
425
380
  if (msg.type === 'auth') {
@@ -439,7 +394,7 @@ export class HeadServer {
439
394
  }
440
395
  // Authenticated messages
441
396
  if (msg.type === 'create_pairing_token') {
442
- this.handleCreatePairingToken(ws, state);
397
+ await this.handleCreatePairingToken(ws, state);
443
398
  return;
444
399
  }
445
400
  if (msg.type === 'unicast') {
@@ -465,7 +420,10 @@ export class HeadServer {
465
420
  // endpoint/disk-growth amplifier from a single client.
466
421
  if (to !== '' && to !== HEAD_PULSE_TARGET) {
467
422
  const target = this.storage.getDevice(to);
468
- 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) {
469
427
  this.sendError(ws, 'unicast target is not reachable');
470
428
  return;
471
429
  }
@@ -526,8 +484,10 @@ export class HeadServer {
526
484
  if (typeof msg.pulse === 'string' && state.deviceId) {
527
485
  // A pushPreview may ride the same pulse envelope — fire it for offline
528
486
  // devices (the reliable message itself is delivered by the hub).
529
- 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') {
530
489
  this.firePushPreview(state, msg.pushPreview);
490
+ }
531
491
  this.pulseHub.onPulseEnvelope(state.deviceId, { pulse: msg.pulse });
532
492
  return;
533
493
  }
@@ -670,6 +630,14 @@ export class HeadServer {
670
630
  trace('HUB-TX-WS', { to: deviceId, online, pulseB64Len: pulseB64.length });
671
631
  if (!online)
672
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
+ }
673
641
  // `to` lets the receiver's own pulse layer know which stream this is; blob
674
642
  // is empty because the payload (if any) is inside the pulse frame.
675
643
  ws.send(JSON.stringify({ type: 'unicast', to: deviceId, pulse: pulseB64, blob: '', keys: {} }));
@@ -732,6 +700,9 @@ export class HeadServer {
732
700
  case 'remove_device':
733
701
  this.handleRemoveDevice(ws, state, msg.deviceId);
734
702
  return true;
703
+ case 'delete_account':
704
+ void this.handleDeleteAccount(ws, state);
705
+ return true;
735
706
  case 'register_push_token':
736
707
  this.handleRegisterPushToken(ws, state, msg);
737
708
  return true;
@@ -924,11 +895,21 @@ export class HeadServer {
924
895
  state.pulseProgressAck = msg.device?.pulseProgressAck === true;
925
896
  const logger = getLogger();
926
897
  const { auth } = msg;
927
- // If auth backend is configured, delegate everything to it
928
- 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 {
929
908
  if (auth.method === 'challenge') {
930
909
  // 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);
910
+ const result = await this.authBackend.startChallenge(auth.deviceId, msg.device.encryptionKey, this.options.region);
911
+ if (!this.socketStillAuthenticating(ws, state))
912
+ return;
932
913
  if (!result.ok) {
933
914
  this.sendAuthError(ws, result.code, result.message, result.code === 'wrong_region' ? result.redirect : undefined);
934
915
  return;
@@ -937,118 +918,24 @@ export class HeadServer {
937
918
  state.pendingDeviceId = result.deviceId;
938
919
  state.pendingDeviceInfo = { encryptionKey: msg.device.encryptionKey };
939
920
  state.pendingAuthMethod = 'challenge';
940
- logger.debug('Issuing auth challenge (via backend)', { deviceId: auth.deviceId });
921
+ logger.debug('Issuing auth challenge', { deviceId: auth.deviceId });
941
922
  ws.send(JSON.stringify({ type: 'auth_challenge', nonce: result.nonce }));
942
923
  return;
943
924
  }
944
- 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;
945
928
  this.handleBackendAuthResult(ws, state, result);
946
- return;
947
929
  }
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
- }
930
+ finally {
931
+ state.authInFlight = false;
1027
932
  }
1028
933
  }
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');
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;
1052
939
  }
1053
940
  handleChallengeResponse(ws, state, msg) {
1054
941
  const logger = getLogger();
@@ -1062,68 +949,25 @@ export class HeadServer {
1062
949
  this.sendAuthError(ws, 'no_pending_challenge', 'No pending challenge');
1063
950
  return;
1064
951
  }
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 });
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)) {
1078
967
  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',
968
+ }
969
+ }).finally(() => {
970
+ state.authInFlight = false;
1127
971
  });
1128
972
  }
1129
973
  /**
@@ -1231,7 +1075,7 @@ export class HeadServer {
1231
1075
  authMethod: params.authMethod,
1232
1076
  user: userResponse,
1233
1077
  devices,
1234
- githubClientId: params.githubClientId ?? this.getGitHubClientId(),
1078
+ githubClientId: params.githubClientId ?? this.authBackend.getAuthInfo().githubClientId,
1235
1079
  vapidPublicKey: params.vapidPublicKey ?? this.getVapidPublicKey(),
1236
1080
  relayVersion: this.options.version,
1237
1081
  pulseAckBytes: PULSE_ACK_EVERY_BYTES,
@@ -1275,59 +1119,26 @@ export class HeadServer {
1275
1119
  // its guarded cleanup. Publish the Pulse disconnect here so capability and
1276
1120
  // connection-scoped state cannot leak across the replacement boundary.
1277
1121
  this.pulseHub.onDeviceDisconnected(deviceId);
1122
+ const previousState = this.clients.get(previous);
1123
+ if (previousState)
1124
+ previousState.evicted = true;
1278
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.
1279
1129
  try {
1280
- previous.terminate();
1130
+ previous.close(DEVICE_REPLACED_CLOSE_CODE, 'replaced');
1281
1131
  }
1282
1132
  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;
1133
+ const force = setTimeout(() => { try {
1134
+ previous.terminate();
1303
1135
  }
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
- });
1136
+ catch { /* already gone */ } }, 2_000);
1137
+ force.unref?.();
1138
+ previous.once('close', () => clearTimeout(force));
1328
1139
  }
1329
1140
  // --- Pairing tokens (in-memory) ---
1330
- handleCreatePairingToken(ws, state) {
1141
+ async handleCreatePairingToken(ws, state) {
1331
1142
  const logger = getLogger();
1332
1143
  if (!(this.options.pairingEnabled ?? true)) {
1333
1144
  this.sendError(ws, 'Pairing is disabled on this relay');
@@ -1337,30 +1148,23 @@ export class HeadServer {
1337
1148
  this.sendError(ws, 'Not authenticated');
1338
1149
  return;
1339
1150
  }
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 });
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) {
1344
1161
  ws.send(JSON.stringify({ type: 'pairing_token_created', token: result.token, expiresIn: result.expiresIn }));
1345
1162
  }
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
1163
  }
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
- }));
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
+ }
1364
1168
  }
1365
1169
  async handleRequestPairingToken(ws, state, msg) {
1366
1170
  const logger = getLogger();
@@ -1372,47 +1176,18 @@ export class HeadServer {
1372
1176
  this.sendError(ws, 'Token required for pairing request');
1373
1177
  return;
1374
1178
  }
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
- }));
1179
+ const result = await this.authBackend.requestPairingToken(msg.token, state.ip);
1180
+ if (ws.readyState !== WebSocket.OPEN)
1388
1181
  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);
1182
+ if (!result.ok) {
1183
+ this.sendAuthError(ws, result.code, result.message);
1402
1184
  return;
1403
1185
  }
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 });
1186
+ logger.info('Pairing token created (one-shot)', { userId: result.userId });
1412
1187
  ws.send(JSON.stringify({
1413
1188
  type: 'pairing_token_created',
1414
- token,
1415
- expiresIn: ttl,
1189
+ token: result.pairingToken,
1190
+ expiresIn: result.expiresIn,
1416
1191
  }));
1417
1192
  }
1418
1193
  // --- Device presence notifications ---
@@ -1447,7 +1222,7 @@ export class HeadServer {
1447
1222
  // Edge mode: the account service owns the device list and every auth
1448
1223
  // mirrors it back here, so the removal must reach it too (otherwise the
1449
1224
  // device reappears after the next reconnect).
1450
- const remote = this.options.authBackend;
1225
+ const remote = this.authBackend;
1451
1226
  if (remote instanceof RemoteAuthBackend) {
1452
1227
  const userId = state.userId;
1453
1228
  remote.removeDevice(userId, targetDeviceId).then((ok) => {
@@ -1460,6 +1235,65 @@ export class HeadServer {
1460
1235
  // Broadcast removal to all remaining user devices
1461
1236
  this.broadcastDeviceRemoved(state.userId, targetDeviceId);
1462
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
+ }
1463
1297
  // --- Push token management ---
1464
1298
  handleRegisterPushToken(ws, state, msg) {
1465
1299
  const logger = getLogger();
@@ -1475,7 +1309,11 @@ export class HeadServer {
1475
1309
  this.storage.upsertPushToken(state.deviceId, payload.provider, payload.token, payload.environment, payload.bundleId);
1476
1310
  // Clean up push tokens from stale devices (offline >24h) to prevent
1477
1311
  // duplicate notifications from abandoned PWA installs on the same phone.
1478
- 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));
1479
1317
  const pruned = this.storage.deleteStaleUserPushTokens(state.userId, state.deviceId, onlineDeviceIds);
1480
1318
  if (pruned > 0) {
1481
1319
  logger.info('Pruned stale push tokens', { userId: state.userId, count: pruned });
@@ -1652,8 +1490,9 @@ export class HeadServer {
1652
1490
  });
1653
1491
  }
1654
1492
  const allUsers = this.storage.getAllUsers();
1493
+ const devicesByUser = this.storage.getAllDevicesByUser();
1655
1494
  const registeredUsers = allUsers.map(u => {
1656
- const devices = this.storage.getDevicesByUser(u.userId);
1495
+ const devices = devicesByUser.get(u.userId) ?? [];
1657
1496
  return {
1658
1497
  userId: u.userId,
1659
1498
  username: u.username,