@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/account-api.d.ts +10 -0
- package/dist/account-api.js +67 -15
- package/dist/account-api.js.map +1 -1
- package/dist/auth-backend.d.ts +16 -5
- package/dist/auth.d.ts +0 -1
- package/dist/auth.js +32 -20
- package/dist/auth.js.map +1 -1
- package/dist/cli.js +12 -1
- package/dist/cli.js.map +1 -1
- package/dist/lease-issuer.js +1 -1
- package/dist/lease-issuer.js.map +1 -1
- package/dist/local-auth-backend.d.ts +5 -1
- package/dist/local-auth-backend.js +30 -17
- package/dist/local-auth-backend.js.map +1 -1
- package/dist/pulse-hub.d.ts +8 -0
- package/dist/pulse-hub.js +51 -3
- package/dist/pulse-hub.js.map +1 -1
- package/dist/push/apns.js +19 -1
- package/dist/push/apns.js.map +1 -1
- package/dist/remote-auth-backend.d.ts +2 -1
- package/dist/remote-auth-backend.js +10 -2
- package/dist/remote-auth-backend.js.map +1 -1
- package/dist/server.d.ts +18 -7
- package/dist/server.js +243 -360
- package/dist/server.js.map +1 -1
- package/dist/storage.d.ts +21 -0
- package/dist/storage.js +103 -11
- package/dist/storage.js.map +1 -1
- package/dist/trace.d.ts +0 -1
- package/dist/trace.js +0 -3
- package/dist/trace.js.map +1 -1
- package/dist/voice-vocabulary.d.ts +14 -0
- package/dist/voice-vocabulary.js +102 -0
- package/dist/voice-vocabulary.js.map +1 -0
- package/package.json +3 -4
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 {
|
|
4
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
886
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
|
|
907
|
-
|
|
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
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
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
|
-
|
|
1024
|
-
|
|
1025
|
-
this.
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
1130
|
+
previous.close(DEVICE_REPLACED_CLOSE_CODE, 'replaced');
|
|
1237
1131
|
}
|
|
1238
1132
|
catch { /* best effort */ }
|
|
1239
|
-
|
|
1240
|
-
|
|
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
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
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
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
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
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
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
|
-
|
|
1332
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
1495
|
+
const devices = devicesByUser.get(u.userId) ?? [];
|
|
1613
1496
|
return {
|
|
1614
1497
|
userId: u.userId,
|
|
1615
1498
|
username: u.username,
|