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