@kraki/head 0.1.0 → 0.2.0
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 +84 -0
- package/dist/cli.d.ts +0 -2
- package/dist/cli.js +6 -14
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +0 -2
- package/dist/index.js +0 -2
- package/dist/index.js.map +1 -1
- package/dist/server.d.ts +19 -49
- package/dist/server.js +405 -438
- package/dist/server.js.map +1 -1
- package/dist/storage.d.ts +4 -86
- package/dist/storage.js +26 -303
- package/dist/storage.js.map +1 -1
- package/package.json +11 -2
- package/dist/channel-manager.d.ts +0 -97
- package/dist/channel-manager.js +0 -215
- package/dist/channel-manager.js.map +0 -1
- package/dist/router.d.ts +0 -30
- package/dist/router.js +0 -217
- package/dist/router.js.map +0 -1
package/dist/server.js
CHANGED
|
@@ -1,40 +1,24 @@
|
|
|
1
1
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
2
2
|
import { randomBytes, createVerify } from 'crypto';
|
|
3
|
+
import { v4 as uuid } from 'uuid';
|
|
3
4
|
import { GitHubAuthProvider } from './auth.js';
|
|
4
5
|
import { getLogger } from './logger.js';
|
|
5
|
-
/**
|
|
6
|
-
* Import a compact base64 public key to PEM format.
|
|
7
|
-
*/
|
|
8
6
|
function importPublicKey(compactKey) {
|
|
9
7
|
const lines = compactKey.match(/.{1,64}/g) ?? [];
|
|
10
8
|
return `-----BEGIN PUBLIC KEY-----\n${lines.join('\n')}\n-----END PUBLIC KEY-----\n`;
|
|
11
9
|
}
|
|
12
|
-
/**
|
|
13
|
-
* Verify a challenge-response signature.
|
|
14
|
-
*/
|
|
15
10
|
function verifySignature(nonce, signature, publicKeyPem) {
|
|
16
11
|
const verify = createVerify('SHA256');
|
|
17
12
|
verify.update(nonce);
|
|
18
13
|
return verify.verify(publicKeyPem, signature, 'base64');
|
|
19
14
|
}
|
|
20
15
|
const DEFAULT_MAX_PAYLOAD = 10 * 1024 * 1024;
|
|
21
|
-
const DEFAULT_PING_INTERVAL = 30_000;
|
|
22
|
-
const DEFAULT_PONG_TIMEOUT = 10_000; // 10MB
|
|
23
|
-
/**
|
|
24
|
-
* Basic message validation guard.
|
|
25
|
-
* Checks structural requirements — not full schema validation.
|
|
26
|
-
*/
|
|
27
16
|
function isValidMessage(msg) {
|
|
28
17
|
if (typeof msg !== 'object' || msg === null)
|
|
29
18
|
return false;
|
|
30
19
|
const obj = msg;
|
|
31
|
-
|
|
32
|
-
return false;
|
|
33
|
-
if ('payload' in obj && (typeof obj.payload !== 'object' || obj.payload === null))
|
|
34
|
-
return false;
|
|
35
|
-
return true;
|
|
20
|
+
return typeof obj.type === 'string' && obj.type.length > 0;
|
|
36
21
|
}
|
|
37
|
-
/** Validate auth message shape */
|
|
38
22
|
function isValidAuth(msg) {
|
|
39
23
|
if (!msg.device || typeof msg.device !== 'object')
|
|
40
24
|
return false;
|
|
@@ -43,39 +27,34 @@ function isValidAuth(msg) {
|
|
|
43
27
|
return false;
|
|
44
28
|
if (dev.role !== 'tentacle' && dev.role !== 'app')
|
|
45
29
|
return false;
|
|
46
|
-
|
|
47
|
-
}
|
|
48
|
-
/** Validate create_session payload */
|
|
49
|
-
function isValidCreateSession(msg) {
|
|
50
|
-
if (!msg.payload || typeof msg.payload !== 'object')
|
|
51
|
-
return false;
|
|
52
|
-
const p = msg.payload;
|
|
53
|
-
if (typeof p.targetDeviceId !== 'string')
|
|
30
|
+
if (!msg.auth || typeof msg.auth !== 'object')
|
|
54
31
|
return false;
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
if (typeof p.requestId !== 'string')
|
|
32
|
+
const auth = msg.auth;
|
|
33
|
+
if (typeof auth.method !== 'string')
|
|
58
34
|
return false;
|
|
59
35
|
return true;
|
|
60
36
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
37
|
+
function isValidUnicast(msg) {
|
|
38
|
+
return msg.type === 'unicast' && typeof msg.to === 'string'
|
|
39
|
+
&& typeof msg.blob === 'string' && typeof msg.keys === 'object' && msg.keys !== null;
|
|
40
|
+
}
|
|
41
|
+
function isValidBroadcast(msg) {
|
|
42
|
+
return msg.type === 'broadcast'
|
|
43
|
+
&& typeof msg.blob === 'string' && typeof msg.keys === 'object' && msg.keys !== null;
|
|
65
44
|
}
|
|
66
45
|
export class HeadServer {
|
|
67
46
|
wss;
|
|
68
|
-
|
|
69
|
-
router;
|
|
47
|
+
storage;
|
|
70
48
|
options;
|
|
49
|
+
static PING_INTERVAL = 30_000;
|
|
50
|
+
// In-memory state
|
|
51
|
+
connections = new Map();
|
|
52
|
+
pairingTokens = new Map();
|
|
53
|
+
userByDevice = new Map();
|
|
71
54
|
clients = new Map();
|
|
72
55
|
pingTimer = null;
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
dedupCleanupTimer = null;
|
|
76
|
-
constructor(cm, router, options) {
|
|
77
|
-
this.cm = cm;
|
|
78
|
-
this.router = router;
|
|
56
|
+
constructor(storage, options) {
|
|
57
|
+
this.storage = storage;
|
|
79
58
|
this.options = options;
|
|
80
59
|
this.wss = new WebSocketServer({
|
|
81
60
|
noServer: true,
|
|
@@ -83,40 +62,38 @@ export class HeadServer {
|
|
|
83
62
|
});
|
|
84
63
|
this.wss.on('connection', (ws, req) => this.onConnection(ws, req));
|
|
85
64
|
this.startPingInterval();
|
|
86
|
-
this.startDedupCleanup();
|
|
87
65
|
}
|
|
88
|
-
|
|
66
|
+
startPingInterval() {
|
|
67
|
+
this.pingTimer = setInterval(() => {
|
|
68
|
+
const msg = JSON.stringify({ type: 'ping' });
|
|
69
|
+
for (const [deviceId, ws] of this.connections) {
|
|
70
|
+
try {
|
|
71
|
+
if (ws.readyState === WebSocket.OPEN)
|
|
72
|
+
ws.send(msg);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
this.removeConnection(deviceId);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}, HeadServer.PING_INTERVAL);
|
|
79
|
+
}
|
|
80
|
+
// --- Auth provider helpers ---
|
|
89
81
|
getAuthProvider() {
|
|
90
82
|
if (this.options.authProviders?.size) {
|
|
91
|
-
// Return first provider as default (actual selection happens per-request)
|
|
92
83
|
return this.options.authProviders.values().next().value;
|
|
93
84
|
}
|
|
94
85
|
return this.options.authProvider;
|
|
95
86
|
}
|
|
96
|
-
/** Get supported auth mode names. */
|
|
97
|
-
getAuthModes() {
|
|
98
|
-
if (this.options.authModes?.length)
|
|
99
|
-
return this.options.authModes;
|
|
100
|
-
if (this.options.authProviders?.size)
|
|
101
|
-
return [...this.options.authProviders.keys()];
|
|
102
|
-
return [this.options.authProvider?.name ?? 'open'];
|
|
103
|
-
}
|
|
104
|
-
/** Resolve auth provider by mode name (for multi-provider). */
|
|
105
87
|
getAuthProviderForMode(mode) {
|
|
106
88
|
if (mode && this.options.authProviders?.has(mode)) {
|
|
107
89
|
return this.options.authProviders.get(mode);
|
|
108
90
|
}
|
|
109
91
|
return this.getAuthProvider();
|
|
110
92
|
}
|
|
111
|
-
/** Get the GitHub OAuth client ID if configured (for auth_info_response). */
|
|
112
93
|
getGitHubClientId() {
|
|
113
94
|
const ghProvider = this.findGitHubProvider();
|
|
114
|
-
|
|
115
|
-
return { githubClientId: ghProvider.getClientId() };
|
|
116
|
-
}
|
|
117
|
-
return {};
|
|
95
|
+
return ghProvider?.oauthConfigured ? ghProvider.getClientId() : undefined;
|
|
118
96
|
}
|
|
119
|
-
/** Find the GitHubAuthProvider in the provider chain (unwrapping throttle). */
|
|
120
97
|
findGitHubProvider() {
|
|
121
98
|
const provider = this.options.authProviders?.get('github') ?? this.options.authProvider;
|
|
122
99
|
if (!provider)
|
|
@@ -130,19 +107,7 @@ export class HeadServer {
|
|
|
130
107
|
}
|
|
131
108
|
return undefined;
|
|
132
109
|
}
|
|
133
|
-
|
|
134
|
-
getChannelOwnerUser(channelId) {
|
|
135
|
-
const channel = this.cm.getStorage().getChannel(channelId);
|
|
136
|
-
if (!channel)
|
|
137
|
-
return undefined;
|
|
138
|
-
const user = this.cm.getStorage().getUser(channel.ownerId);
|
|
139
|
-
if (!user)
|
|
140
|
-
return undefined;
|
|
141
|
-
return { id: user.userId, login: user.username, provider: user.provider, email: user.email };
|
|
142
|
-
}
|
|
143
|
-
/**
|
|
144
|
-
* Attach to an HTTP server for upgrade handling.
|
|
145
|
-
*/
|
|
110
|
+
// --- Connection management ---
|
|
146
111
|
attach(server) {
|
|
147
112
|
server.on('upgrade', (req, socket, head) => {
|
|
148
113
|
this.wss.handleUpgrade(req, socket, head, (ws) => {
|
|
@@ -150,23 +115,16 @@ export class HeadServer {
|
|
|
150
115
|
});
|
|
151
116
|
});
|
|
152
117
|
}
|
|
153
|
-
/**
|
|
154
|
-
* Accept a raw WebSocket connection (for testing without HTTP server).
|
|
155
|
-
*/
|
|
156
118
|
acceptConnection(ws) {
|
|
157
119
|
this.onConnection(ws);
|
|
158
120
|
}
|
|
159
121
|
onConnection(ws, req) {
|
|
160
122
|
const ip = req?.socket?.remoteAddress ?? req?.headers['x-forwarded-for']?.toString() ?? 'unknown';
|
|
161
|
-
const state = { authenticated: false, ip
|
|
123
|
+
const state = { authenticated: false, ip };
|
|
162
124
|
const logger = getLogger();
|
|
163
125
|
this.clients.set(ws, state);
|
|
164
126
|
logger.debug('WebSocket connected', { ip });
|
|
165
|
-
ws.on('pong', () => {
|
|
166
|
-
state.alive = true;
|
|
167
|
-
});
|
|
168
127
|
ws.on('message', (data) => {
|
|
169
|
-
state.alive = true;
|
|
170
128
|
try {
|
|
171
129
|
const msg = JSON.parse(data.toString());
|
|
172
130
|
if (!isValidMessage(msg)) {
|
|
@@ -183,15 +141,15 @@ export class HeadServer {
|
|
|
183
141
|
}
|
|
184
142
|
});
|
|
185
143
|
ws.on('close', () => {
|
|
186
|
-
if (state.deviceId
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
144
|
+
if (state.deviceId) {
|
|
145
|
+
const disconnectedDeviceId = state.deviceId;
|
|
146
|
+
const disconnectedUserId = state.userId;
|
|
147
|
+
this.connections.delete(disconnectedDeviceId);
|
|
148
|
+
this.userByDevice.delete(disconnectedDeviceId);
|
|
149
|
+
logger.info('Device disconnected', { deviceId: disconnectedDeviceId });
|
|
150
|
+
// Notify other connected devices that this device left
|
|
151
|
+
if (disconnectedUserId) {
|
|
152
|
+
this.broadcastDeviceLeft(disconnectedUserId, disconnectedDeviceId);
|
|
195
153
|
}
|
|
196
154
|
}
|
|
197
155
|
this.clients.delete(ws);
|
|
@@ -201,32 +159,32 @@ export class HeadServer {
|
|
|
201
159
|
});
|
|
202
160
|
}
|
|
203
161
|
async onMessage(ws, state, msg) {
|
|
204
|
-
//
|
|
205
|
-
if (msg.type === 'ping') {
|
|
206
|
-
ws.send(JSON.stringify({ type: 'pong' }));
|
|
207
|
-
return;
|
|
208
|
-
}
|
|
209
|
-
// Dedup: if client provides a clientMsgId, check for duplicates
|
|
210
|
-
if (typeof msg.clientMsgId === 'string') {
|
|
211
|
-
if (this.trackClientMsgId(msg.clientMsgId)) {
|
|
212
|
-
getLogger().debug('Duplicate message dropped', { clientMsgId: msg.clientMsgId });
|
|
213
|
-
return;
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
// One-shot pairing token request — no device registration, handled before auth
|
|
162
|
+
// One-shot pairing token request — before auth
|
|
217
163
|
if (msg.type === 'request_pairing_token') {
|
|
218
164
|
await this.handleRequestPairingToken(ws, state, msg);
|
|
219
165
|
return;
|
|
220
166
|
}
|
|
221
|
-
//
|
|
167
|
+
// Pre-auth: auth_info, auth, auth_response
|
|
222
168
|
if (!state.authenticated) {
|
|
223
169
|
if (msg.type === 'auth_info') {
|
|
170
|
+
const methods = [];
|
|
171
|
+
if (this.options.authProviders?.has('github')) {
|
|
172
|
+
methods.push('github_token');
|
|
173
|
+
const ghProvider = this.findGitHubProvider();
|
|
174
|
+
if (ghProvider?.oauthConfigured)
|
|
175
|
+
methods.push('github_oauth');
|
|
176
|
+
}
|
|
177
|
+
if (this.options.authProviders?.has('apikey'))
|
|
178
|
+
methods.push('apikey');
|
|
179
|
+
if (this.options.authProviders?.has('open'))
|
|
180
|
+
methods.push('open');
|
|
181
|
+
if (this.options.pairingEnabled !== false)
|
|
182
|
+
methods.push('pairing');
|
|
183
|
+
methods.push('challenge');
|
|
224
184
|
ws.send(JSON.stringify({
|
|
225
185
|
type: 'auth_info_response',
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
pairing: this.options.pairingEnabled !== false,
|
|
229
|
-
...this.getGitHubClientId(),
|
|
186
|
+
methods,
|
|
187
|
+
githubClientId: this.getGitHubClientId(),
|
|
230
188
|
}));
|
|
231
189
|
return;
|
|
232
190
|
}
|
|
@@ -245,430 +203,439 @@ export class HeadServer {
|
|
|
245
203
|
}
|
|
246
204
|
return;
|
|
247
205
|
}
|
|
248
|
-
//
|
|
249
|
-
if (msg.type === '
|
|
250
|
-
this.
|
|
251
|
-
return;
|
|
252
|
-
}
|
|
253
|
-
if (msg.type === 'create_session' && !isValidCreateSession(msg)) {
|
|
254
|
-
this.sendError(ws, 'Invalid create_session: requestId, targetDeviceId, model required');
|
|
206
|
+
// Authenticated messages
|
|
207
|
+
if (msg.type === 'create_pairing_token') {
|
|
208
|
+
this.handleCreatePairingToken(ws, state);
|
|
255
209
|
return;
|
|
256
210
|
}
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
if (!state.deviceId)
|
|
211
|
+
if (msg.type === 'unicast') {
|
|
212
|
+
if (!isValidUnicast(msg)) {
|
|
213
|
+
this.sendError(ws, 'Invalid unicast: to, blob, keys required');
|
|
261
214
|
return;
|
|
262
|
-
|
|
215
|
+
}
|
|
216
|
+
this.handleUnicast(ws, state, msg);
|
|
263
217
|
return;
|
|
264
218
|
}
|
|
265
|
-
if (msg.type === '
|
|
266
|
-
if (!
|
|
219
|
+
if (msg.type === 'broadcast') {
|
|
220
|
+
if (!isValidBroadcast(msg)) {
|
|
221
|
+
this.sendError(ws, 'Invalid broadcast: blob, keys required');
|
|
267
222
|
return;
|
|
268
|
-
const { sessionId, seq } = msg;
|
|
269
|
-
if (sessionId && typeof seq === 'number') {
|
|
270
|
-
this.cm.getStorage().markRead(state.channelId, sessionId, seq);
|
|
271
|
-
// Notify other devices so they can sync unread state
|
|
272
|
-
this.router.broadcastNotice(state.channelId, {
|
|
273
|
-
type: 'head_notice',
|
|
274
|
-
event: 'read_state_updated',
|
|
275
|
-
data: { sessionId, lastSeq: seq },
|
|
276
|
-
});
|
|
277
223
|
}
|
|
224
|
+
this.handleBroadcast(state, msg);
|
|
278
225
|
return;
|
|
279
226
|
}
|
|
280
|
-
if (msg.type === '
|
|
281
|
-
|
|
282
|
-
return;
|
|
283
|
-
}
|
|
284
|
-
if (msg.type === 'delete_session') {
|
|
285
|
-
this.handleDeleteSession(ws, state, msg);
|
|
227
|
+
if (msg.type === 'ping') {
|
|
228
|
+
ws.send(JSON.stringify({ type: 'pong' }));
|
|
286
229
|
return;
|
|
287
230
|
}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
this.router.handleMessage(state.deviceId, msg);
|
|
231
|
+
if (msg.type === 'pong') {
|
|
232
|
+
return; // silently accept pong responses
|
|
291
233
|
}
|
|
234
|
+
this.sendError(ws, `Unknown message type: ${msg.type}`);
|
|
292
235
|
}
|
|
293
|
-
|
|
236
|
+
// --- Routing ---
|
|
237
|
+
handleUnicast(ws, state, msg) {
|
|
294
238
|
const logger = getLogger();
|
|
295
|
-
|
|
296
|
-
|
|
239
|
+
const senderUserId = state.userId;
|
|
240
|
+
if (!senderUserId || !state.deviceId)
|
|
241
|
+
return;
|
|
242
|
+
// Verify target belongs to same user
|
|
243
|
+
const targetDevice = this.storage.getDevice(msg.to);
|
|
244
|
+
if (!targetDevice || targetDevice.userId !== senderUserId) {
|
|
245
|
+
logger.warn('Unicast rejected: target not found or wrong user', { from: state.deviceId, to: msg.to });
|
|
246
|
+
ws.send(JSON.stringify({ type: 'server_error', message: 'Target device not found or offline', ref: msg.ref }));
|
|
297
247
|
return;
|
|
298
248
|
}
|
|
299
|
-
|
|
300
|
-
|
|
249
|
+
const targetWs = this.connections.get(msg.to);
|
|
250
|
+
if (!targetWs) {
|
|
251
|
+
logger.debug('Unicast target offline', { to: msg.to });
|
|
252
|
+
ws.send(JSON.stringify({ type: 'server_error', message: 'Target device not found or offline', ref: msg.ref }));
|
|
301
253
|
return;
|
|
302
254
|
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
}));
|
|
255
|
+
try {
|
|
256
|
+
if (targetWs.readyState === WebSocket.OPEN) {
|
|
257
|
+
targetWs.send(JSON.stringify(msg));
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
logger.warn('Unicast send failed, removing connection', { to: msg.to });
|
|
262
|
+
this.removeConnection(msg.to);
|
|
263
|
+
}
|
|
313
264
|
}
|
|
314
|
-
|
|
265
|
+
handleBroadcast(state, msg) {
|
|
315
266
|
const logger = getLogger();
|
|
316
|
-
|
|
317
|
-
|
|
267
|
+
const senderUserId = state.userId;
|
|
268
|
+
const senderDeviceId = state.deviceId;
|
|
269
|
+
if (!senderUserId || !senderDeviceId)
|
|
318
270
|
return;
|
|
271
|
+
// Find all other connected devices for this user
|
|
272
|
+
const userDevices = this.storage.getDevicesByUser(senderUserId);
|
|
273
|
+
for (const device of userDevices) {
|
|
274
|
+
if (device.id === senderDeviceId)
|
|
275
|
+
continue;
|
|
276
|
+
const targetWs = this.connections.get(device.id);
|
|
277
|
+
if (!targetWs)
|
|
278
|
+
continue;
|
|
279
|
+
try {
|
|
280
|
+
if (targetWs.readyState === WebSocket.OPEN) {
|
|
281
|
+
targetWs.send(JSON.stringify(msg));
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
logger.warn('Broadcast send failed, removing connection', { to: device.id });
|
|
286
|
+
this.removeConnection(device.id);
|
|
287
|
+
}
|
|
319
288
|
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
289
|
+
}
|
|
290
|
+
removeConnection(deviceId) {
|
|
291
|
+
const ws = this.connections.get(deviceId);
|
|
292
|
+
this.connections.delete(deviceId);
|
|
293
|
+
this.userByDevice.delete(deviceId);
|
|
294
|
+
if (ws) {
|
|
295
|
+
try {
|
|
296
|
+
ws.close();
|
|
297
|
+
}
|
|
298
|
+
catch { /* best effort */ }
|
|
323
299
|
}
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
300
|
+
}
|
|
301
|
+
sendAuthError(ws, code, message) {
|
|
302
|
+
ws.send(JSON.stringify({ type: 'auth_error', code, message }));
|
|
303
|
+
}
|
|
304
|
+
// --- Auth ---
|
|
305
|
+
async handleAuth(ws, state, msg) {
|
|
306
|
+
const logger = getLogger();
|
|
307
|
+
const { auth } = msg;
|
|
308
|
+
switch (auth.method) {
|
|
309
|
+
case 'pairing': {
|
|
310
|
+
this.handlePairingAuth(ws, state, auth.token, msg);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
case 'challenge': {
|
|
314
|
+
const device = this.storage.getDevice(auth.deviceId);
|
|
315
|
+
if (device && device.publicKey) {
|
|
316
|
+
const nonce = randomBytes(32).toString('hex');
|
|
317
|
+
state.pendingNonce = nonce;
|
|
318
|
+
state.pendingDeviceId = auth.deviceId;
|
|
319
|
+
state.pendingDeviceInfo = { encryptionKey: msg.device.encryptionKey };
|
|
320
|
+
state.pendingAuthMethod = 'challenge';
|
|
321
|
+
logger.debug('Issuing auth challenge', { deviceId: auth.deviceId });
|
|
322
|
+
ws.send(JSON.stringify({ type: 'auth_challenge', nonce }));
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
this.sendAuthError(ws, 'unknown_device', 'Unknown device');
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
case 'github_token': {
|
|
329
|
+
const provider = this.getAuthProviderForMode('github');
|
|
330
|
+
const result = await provider.authenticate({ token: auth.token, ip: state.ip });
|
|
331
|
+
if (!result.ok) {
|
|
332
|
+
logger.warn('Auth rejected', { method: 'github_token', ip: state.ip, reason: result.message });
|
|
333
|
+
this.sendAuthError(ws, 'auth_rejected', result.message);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
this.completeAuth(ws, state, result.user, msg, 'github_token');
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
case 'github_oauth': {
|
|
340
|
+
const provider = this.getAuthProviderForMode('github');
|
|
341
|
+
const result = await provider.authenticate({ githubCode: auth.code, ip: state.ip });
|
|
342
|
+
if (!result.ok) {
|
|
343
|
+
logger.warn('Auth rejected', { method: 'github_oauth', ip: state.ip, reason: result.message });
|
|
344
|
+
this.sendAuthError(ws, 'auth_rejected', result.message);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
this.completeAuth(ws, state, result.user, msg, 'github_oauth');
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
case 'apikey': {
|
|
351
|
+
const provider = this.getAuthProviderForMode('apikey');
|
|
352
|
+
const result = await provider.authenticate({ token: auth.key, ip: state.ip });
|
|
353
|
+
if (!result.ok) {
|
|
354
|
+
logger.warn('Auth rejected', { method: 'apikey', ip: state.ip, reason: result.message });
|
|
355
|
+
this.sendAuthError(ws, 'auth_rejected', result.message);
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
this.completeAuth(ws, state, result.user, msg, 'apikey');
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
case 'open': {
|
|
362
|
+
const provider = this.getAuthProviderForMode('open');
|
|
363
|
+
const result = await provider.authenticate({ token: auth.sharedKey, ip: state.ip });
|
|
364
|
+
if (!result.ok) {
|
|
365
|
+
logger.warn('Auth rejected', { method: 'open', ip: state.ip, reason: result.message });
|
|
366
|
+
this.sendAuthError(ws, 'auth_rejected', result.message);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
this.completeAuth(ws, state, result.user, msg, 'open');
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
default: {
|
|
373
|
+
const method = auth.method;
|
|
374
|
+
this.sendAuthError(ws, 'unknown_auth_method', `Unknown auth method: ${method}`);
|
|
375
|
+
}
|
|
328
376
|
}
|
|
329
|
-
this.cm.deleteSession(msg.sessionId, state.channelId);
|
|
330
|
-
logger.info('Session deleted', { sessionId: msg.sessionId, channelId: state.channelId });
|
|
331
|
-
this.router.broadcastNotice(state.channelId, {
|
|
332
|
-
type: 'head_notice',
|
|
333
|
-
event: 'session_removed',
|
|
334
|
-
data: { sessionId: msg.sessionId },
|
|
335
|
-
});
|
|
336
377
|
}
|
|
337
|
-
|
|
338
|
-
* One-shot pairing token request. Validates auth token inline,
|
|
339
|
-
* creates a pairing token, responds, and requires no device registration.
|
|
340
|
-
*/
|
|
341
|
-
async handleRequestPairingToken(ws, state, msg) {
|
|
378
|
+
handlePairingAuth(ws, state, pairingToken, msg) {
|
|
342
379
|
const logger = getLogger();
|
|
343
380
|
if (!(this.options.pairingEnabled ?? true)) {
|
|
344
|
-
this.
|
|
381
|
+
this.sendAuthError(ws, 'pairing_disabled', 'Pairing is disabled.');
|
|
345
382
|
return;
|
|
346
383
|
}
|
|
347
|
-
|
|
348
|
-
|
|
384
|
+
const tokenData = this.pairingTokens.get(pairingToken);
|
|
385
|
+
if (!tokenData || tokenData.expiresAt < Date.now()) {
|
|
386
|
+
if (tokenData)
|
|
387
|
+
this.pairingTokens.delete(pairingToken);
|
|
388
|
+
logger.warn('Pairing auth failed: invalid or expired token', { ip: state.ip });
|
|
389
|
+
this.sendAuthError(ws, 'invalid_pairing_token', 'Invalid or expired pairing token');
|
|
349
390
|
return;
|
|
350
391
|
}
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
ws.send(JSON.stringify({ type: 'auth_error', message: authResult.message }));
|
|
392
|
+
// Consume token (single-use)
|
|
393
|
+
this.pairingTokens.delete(pairingToken);
|
|
394
|
+
const user = this.storage.getUser(tokenData.userId);
|
|
395
|
+
if (!user) {
|
|
396
|
+
this.sendAuthError(ws, 'user_not_found', 'User not found');
|
|
357
397
|
return;
|
|
358
398
|
}
|
|
359
|
-
const
|
|
360
|
-
|
|
361
|
-
const ttl = this.options.pairingTtl ?? 300;
|
|
362
|
-
const expiresAt = new Date(Date.now() + ttl * 1000).toISOString();
|
|
363
|
-
this.cm.getStorage().createPairingToken(token, channelId, expiresAt);
|
|
364
|
-
logger.info('Pairing token created (one-shot)', { channelId, ttl });
|
|
365
|
-
ws.send(JSON.stringify({
|
|
366
|
-
type: 'pairing_token_created',
|
|
367
|
-
token,
|
|
368
|
-
expiresIn: ttl,
|
|
369
|
-
}));
|
|
399
|
+
const authUser = { id: user.userId, login: user.username, provider: user.provider, email: user.email };
|
|
400
|
+
this.completeAuth(ws, state, authUser, msg, 'pairing');
|
|
370
401
|
}
|
|
371
|
-
|
|
402
|
+
handleChallengeResponse(ws, state, msg) {
|
|
372
403
|
const logger = getLogger();
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
404
|
+
const deviceId = state.pendingDeviceId;
|
|
405
|
+
const nonce = state.pendingNonce;
|
|
406
|
+
const pendingInfo = state.pendingDeviceInfo;
|
|
407
|
+
state.pendingNonce = undefined;
|
|
408
|
+
state.pendingDeviceId = undefined;
|
|
409
|
+
state.pendingDeviceInfo = undefined;
|
|
410
|
+
if (!deviceId || !nonce) {
|
|
411
|
+
this.sendAuthError(ws, 'no_pending_challenge', 'No pending challenge');
|
|
376
412
|
return;
|
|
377
413
|
}
|
|
378
|
-
|
|
379
|
-
if (
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
// Known device with public key — issue challenge
|
|
383
|
-
const nonce = randomBytes(32).toString('hex');
|
|
384
|
-
state.pendingNonce = nonce;
|
|
385
|
-
state.pendingDeviceId = msg.device.deviceId;
|
|
386
|
-
state.pendingDeviceInfo = {
|
|
387
|
-
encryptionKey: msg.device.encryptionKey,
|
|
388
|
-
capabilities: msg.device.capabilities,
|
|
389
|
-
};
|
|
390
|
-
logger.debug('Issuing auth challenge', { deviceId: msg.device.deviceId });
|
|
391
|
-
ws.send(JSON.stringify({ type: 'auth_challenge', nonce }));
|
|
392
|
-
return;
|
|
393
|
-
}
|
|
414
|
+
const device = this.storage.getDevice(deviceId);
|
|
415
|
+
if (!device || !device.publicKey) {
|
|
416
|
+
this.sendAuthError(ws, 'device_not_found', 'Device not found');
|
|
417
|
+
return;
|
|
394
418
|
}
|
|
395
|
-
|
|
396
|
-
const
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
if (!
|
|
407
|
-
|
|
408
|
-
ws.send(JSON.stringify({ type: 'auth_error', message: authResult.message }));
|
|
419
|
+
const publicKeyPem = importPublicKey(device.publicKey);
|
|
420
|
+
const valid = verifySignature(nonce, msg.signature, publicKeyPem);
|
|
421
|
+
if (!valid) {
|
|
422
|
+
logger.warn('Challenge-response auth failed', { deviceId, ip: state.ip });
|
|
423
|
+
this.sendAuthError(ws, 'invalid_signature', 'Invalid signature');
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
// Update encryption key if provided
|
|
427
|
+
const encryptionKey = pendingInfo?.encryptionKey ?? device.encryptionKey ?? undefined;
|
|
428
|
+
this.storage.upsertDevice(deviceId, device.userId, device.name, device.role, device.kind ?? undefined, device.publicKey ?? undefined, encryptionKey);
|
|
429
|
+
const user = this.storage.getUser(device.userId);
|
|
430
|
+
if (!user) {
|
|
431
|
+
this.sendAuthError(ws, 'user_not_found', 'User not found');
|
|
409
432
|
return;
|
|
410
433
|
}
|
|
411
|
-
|
|
412
|
-
|
|
434
|
+
// Register connection
|
|
435
|
+
state.authenticated = true;
|
|
436
|
+
state.deviceId = deviceId;
|
|
437
|
+
state.userId = user.userId;
|
|
438
|
+
this.connections.set(deviceId, ws);
|
|
439
|
+
this.userByDevice.set(deviceId, user.userId);
|
|
440
|
+
logger.info('Device authenticated via challenge-response', { deviceId, ip: state.ip });
|
|
441
|
+
ws.send(JSON.stringify({
|
|
442
|
+
type: 'auth_ok',
|
|
443
|
+
deviceId,
|
|
444
|
+
authMethod: 'challenge',
|
|
445
|
+
user: { id: user.userId, login: user.username, provider: user.provider },
|
|
446
|
+
devices: this.getDeviceSummaries(user.userId),
|
|
447
|
+
githubClientId: this.getGitHubClientId(),
|
|
448
|
+
relayVersion: this.options.version,
|
|
449
|
+
}));
|
|
450
|
+
// Notify other connected devices about the reconnected device
|
|
451
|
+
this.broadcastDeviceJoined(user.userId, deviceId);
|
|
452
|
+
}
|
|
453
|
+
completeAuth(ws, state, user, msg, authMethod) {
|
|
454
|
+
const logger = getLogger();
|
|
455
|
+
// Persist user
|
|
456
|
+
this.storage.upsertUser(user.id, user.login, user.provider, user.email);
|
|
457
|
+
// Register device
|
|
458
|
+
const deviceId = msg.device.deviceId ?? `dev_${uuid().slice(0, 12)}`;
|
|
413
459
|
try {
|
|
414
|
-
deviceId
|
|
415
|
-
channelId,
|
|
416
|
-
name: msg.device.name,
|
|
417
|
-
role: msg.device.role,
|
|
418
|
-
send: (data) => {
|
|
419
|
-
if (ws.readyState === WebSocket.OPEN)
|
|
420
|
-
ws.send(data);
|
|
421
|
-
},
|
|
422
|
-
kind: msg.device.kind,
|
|
423
|
-
publicKey: msg.device.publicKey,
|
|
424
|
-
encryptionKey: msg.device.encryptionKey,
|
|
425
|
-
capabilities: msg.device.capabilities,
|
|
426
|
-
clientDeviceId: msg.device.deviceId,
|
|
427
|
-
});
|
|
460
|
+
this.storage.upsertDevice(deviceId, user.id, msg.device.name, msg.device.role, msg.device.kind, msg.device.publicKey, msg.device.encryptionKey);
|
|
428
461
|
}
|
|
429
462
|
catch (err) {
|
|
430
463
|
logger.warn('Device registration failed', { ip: state.ip, error: err.message });
|
|
431
|
-
|
|
464
|
+
this.sendAuthError(ws, 'device_registration_failed', err.message);
|
|
432
465
|
return;
|
|
433
466
|
}
|
|
434
467
|
state.authenticated = true;
|
|
435
468
|
state.deviceId = deviceId;
|
|
436
|
-
state.
|
|
469
|
+
state.userId = user.id;
|
|
470
|
+
this.connections.set(deviceId, ws);
|
|
471
|
+
this.userByDevice.set(deviceId, user.id);
|
|
437
472
|
logger.info('Device authenticated', {
|
|
438
473
|
deviceId,
|
|
439
474
|
name: msg.device.name,
|
|
440
475
|
role: msg.device.role,
|
|
441
|
-
user:
|
|
476
|
+
user: user.login,
|
|
442
477
|
ip: state.ip,
|
|
443
478
|
});
|
|
444
479
|
ws.send(JSON.stringify({
|
|
445
480
|
type: 'auth_ok',
|
|
446
|
-
channel: channelId,
|
|
447
481
|
deviceId,
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
...this.getGitHubClientId(),
|
|
482
|
+
authMethod,
|
|
483
|
+
user: { id: user.id, login: user.login, provider: user.provider },
|
|
484
|
+
devices: this.getDeviceSummaries(user.id),
|
|
485
|
+
githubClientId: this.getGitHubClientId(),
|
|
486
|
+
relayVersion: this.options.version,
|
|
454
487
|
}));
|
|
455
|
-
// Notify other devices
|
|
456
|
-
this.
|
|
457
|
-
type: 'head_notice',
|
|
458
|
-
event: 'device_online',
|
|
459
|
-
data: {
|
|
460
|
-
device: {
|
|
461
|
-
id: deviceId,
|
|
462
|
-
name: msg.device.name,
|
|
463
|
-
role: msg.device.role,
|
|
464
|
-
kind: msg.device.kind,
|
|
465
|
-
publicKey: msg.device.publicKey,
|
|
466
|
-
encryptionKey: msg.device.encryptionKey,
|
|
467
|
-
capabilities: msg.device.capabilities,
|
|
468
|
-
online: true,
|
|
469
|
-
},
|
|
470
|
-
},
|
|
471
|
-
});
|
|
488
|
+
// Notify other connected devices about the new device
|
|
489
|
+
this.broadcastDeviceJoined(user.id, deviceId);
|
|
472
490
|
}
|
|
473
|
-
|
|
491
|
+
// --- Pairing tokens (in-memory) ---
|
|
492
|
+
handleCreatePairingToken(ws, state) {
|
|
474
493
|
const logger = getLogger();
|
|
475
494
|
if (!(this.options.pairingEnabled ?? true)) {
|
|
476
|
-
|
|
477
|
-
return;
|
|
478
|
-
}
|
|
479
|
-
const pairingToken = msg.pairingToken;
|
|
480
|
-
const channelId = this.cm.getStorage().consumePairingToken(pairingToken);
|
|
481
|
-
if (!channelId) {
|
|
482
|
-
logger.warn('Pairing auth failed: invalid or expired token', { ip: state.ip });
|
|
483
|
-
ws.send(JSON.stringify({ type: 'auth_error', message: 'Invalid or expired pairing token' }));
|
|
495
|
+
this.sendError(ws, 'Pairing is disabled on this relay');
|
|
484
496
|
return;
|
|
485
497
|
}
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
deviceId = this.cm.registerDevice({
|
|
489
|
-
channelId,
|
|
490
|
-
name: msg.device.name,
|
|
491
|
-
role: msg.device.role,
|
|
492
|
-
send: (data) => {
|
|
493
|
-
if (ws.readyState === WebSocket.OPEN)
|
|
494
|
-
ws.send(data);
|
|
495
|
-
},
|
|
496
|
-
kind: msg.device.kind,
|
|
497
|
-
publicKey: msg.device.publicKey,
|
|
498
|
-
encryptionKey: msg.device.encryptionKey,
|
|
499
|
-
capabilities: msg.device.capabilities,
|
|
500
|
-
clientDeviceId: msg.device.deviceId,
|
|
501
|
-
});
|
|
502
|
-
}
|
|
503
|
-
catch (err) {
|
|
504
|
-
logger.warn('Device registration failed (pairing)', { ip: state.ip, error: err.message });
|
|
505
|
-
ws.send(JSON.stringify({ type: 'auth_error', message: err.message }));
|
|
498
|
+
if (!state.userId) {
|
|
499
|
+
this.sendError(ws, 'Not authenticated');
|
|
506
500
|
return;
|
|
507
501
|
}
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
502
|
+
const token = `pt_${randomBytes(32).toString('hex')}`;
|
|
503
|
+
const ttl = this.options.pairingTtl ?? 300;
|
|
504
|
+
this.pairingTokens.set(token, {
|
|
505
|
+
userId: state.userId,
|
|
506
|
+
expiresAt: Date.now() + ttl * 1000,
|
|
507
|
+
});
|
|
508
|
+
logger.info('Pairing token created', { userId: state.userId, ttl });
|
|
512
509
|
ws.send(JSON.stringify({
|
|
513
|
-
type: '
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
e2e: this.options.e2e,
|
|
517
|
-
devices: this.cm.getDeviceSummaries(channelId),
|
|
518
|
-
sessions: this.cm.getSessionSummaries(channelId),
|
|
519
|
-
readState: this.cm.getStorage().getReadState(channelId),
|
|
520
|
-
user: this.getChannelOwnerUser(channelId),
|
|
521
|
-
...this.getGitHubClientId(),
|
|
510
|
+
type: 'pairing_token_created',
|
|
511
|
+
token,
|
|
512
|
+
expiresIn: ttl,
|
|
522
513
|
}));
|
|
523
|
-
this.router.broadcastNotice(channelId, {
|
|
524
|
-
type: 'head_notice',
|
|
525
|
-
event: 'device_online',
|
|
526
|
-
data: {
|
|
527
|
-
device: {
|
|
528
|
-
id: deviceId,
|
|
529
|
-
name: msg.device.name,
|
|
530
|
-
role: msg.device.role,
|
|
531
|
-
kind: msg.device.kind,
|
|
532
|
-
publicKey: msg.device.publicKey,
|
|
533
|
-
encryptionKey: msg.device.encryptionKey,
|
|
534
|
-
capabilities: msg.device.capabilities,
|
|
535
|
-
online: true,
|
|
536
|
-
},
|
|
537
|
-
},
|
|
538
|
-
});
|
|
539
514
|
}
|
|
540
|
-
|
|
515
|
+
async handleRequestPairingToken(ws, state, msg) {
|
|
541
516
|
const logger = getLogger();
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
const pendingInfo = state.pendingDeviceInfo;
|
|
545
|
-
state.pendingNonce = undefined;
|
|
546
|
-
state.pendingDeviceId = undefined;
|
|
547
|
-
state.pendingDeviceInfo = undefined;
|
|
548
|
-
if (!deviceId || !nonce) {
|
|
549
|
-
ws.send(JSON.stringify({ type: 'auth_error', message: 'No pending challenge' }));
|
|
517
|
+
if (!(this.options.pairingEnabled ?? true)) {
|
|
518
|
+
this.sendError(ws, 'Pairing is disabled on this relay');
|
|
550
519
|
return;
|
|
551
520
|
}
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
ws.send(JSON.stringify({ type: 'auth_error', message: 'Device not found' }));
|
|
521
|
+
if (!msg.token) {
|
|
522
|
+
this.sendError(ws, 'Token required for pairing request');
|
|
555
523
|
return;
|
|
556
524
|
}
|
|
557
|
-
//
|
|
558
|
-
|
|
559
|
-
const
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
return;
|
|
525
|
+
// Try all configured auth providers until one succeeds
|
|
526
|
+
let authResult = { ok: false, message: 'No auth provider accepted the token' };
|
|
527
|
+
for (const provider of (this.options.authProviders?.values() ?? [])) {
|
|
528
|
+
authResult = await provider.authenticate({ token: msg.token, ip: state.ip });
|
|
529
|
+
if (authResult.ok)
|
|
530
|
+
break;
|
|
564
531
|
}
|
|
565
|
-
//
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
try {
|
|
569
|
-
registeredId = this.cm.registerDevice({
|
|
570
|
-
channelId: device.channelId,
|
|
571
|
-
name: device.name,
|
|
572
|
-
role: device.role,
|
|
573
|
-
send: (data) => {
|
|
574
|
-
if (ws.readyState === WebSocket.OPEN)
|
|
575
|
-
ws.send(data);
|
|
576
|
-
},
|
|
577
|
-
kind: device.kind ?? undefined,
|
|
578
|
-
publicKey: device.publicKey ?? undefined,
|
|
579
|
-
encryptionKey,
|
|
580
|
-
clientDeviceId: deviceId,
|
|
581
|
-
});
|
|
532
|
+
// Fall back to legacy single provider if no authProviders map
|
|
533
|
+
if (!authResult.ok && this.options.authProvider && !this.options.authProviders?.size) {
|
|
534
|
+
authResult = await this.options.authProvider.authenticate({ token: msg.token, ip: state.ip });
|
|
582
535
|
}
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
ws.send(JSON.stringify({ type: 'auth_error', message: err.message }));
|
|
536
|
+
if (!authResult.ok) {
|
|
537
|
+
this.sendAuthError(ws, 'auth_rejected', authResult.message);
|
|
586
538
|
return;
|
|
587
539
|
}
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
540
|
+
// Ensure user exists
|
|
541
|
+
this.storage.upsertUser(authResult.user.id, authResult.user.login, authResult.user.provider, authResult.user.email);
|
|
542
|
+
const token = `pt_${randomBytes(32).toString('hex')}`;
|
|
543
|
+
const ttl = this.options.pairingTtl ?? 300;
|
|
544
|
+
this.pairingTokens.set(token, {
|
|
545
|
+
userId: authResult.user.id,
|
|
546
|
+
expiresAt: Date.now() + ttl * 1000,
|
|
547
|
+
});
|
|
548
|
+
logger.info('Pairing token created (one-shot)', { userId: authResult.user.id, ttl });
|
|
592
549
|
ws.send(JSON.stringify({
|
|
593
|
-
type: '
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
e2e: this.options.e2e,
|
|
597
|
-
devices: this.cm.getDeviceSummaries(device.channelId),
|
|
598
|
-
sessions: this.cm.getSessionSummaries(device.channelId),
|
|
599
|
-
readState: this.cm.getStorage().getReadState(device.channelId),
|
|
600
|
-
user: this.getChannelOwnerUser(device.channelId),
|
|
601
|
-
...this.getGitHubClientId(),
|
|
550
|
+
type: 'pairing_token_created',
|
|
551
|
+
token,
|
|
552
|
+
expiresIn: ttl,
|
|
602
553
|
}));
|
|
603
|
-
this.router.broadcastNotice(device.channelId, {
|
|
604
|
-
type: 'head_notice',
|
|
605
|
-
event: 'device_online',
|
|
606
|
-
data: {
|
|
607
|
-
device: {
|
|
608
|
-
id: registeredId,
|
|
609
|
-
name: device.name,
|
|
610
|
-
role: device.role,
|
|
611
|
-
kind: device.kind ?? undefined,
|
|
612
|
-
publicKey: device.publicKey ?? undefined,
|
|
613
|
-
encryptionKey,
|
|
614
|
-
online: true,
|
|
615
|
-
},
|
|
616
|
-
},
|
|
617
|
-
});
|
|
618
554
|
}
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
555
|
+
// --- Device presence notifications ---
|
|
556
|
+
broadcastDeviceJoined(userId, newDeviceId) {
|
|
557
|
+
let device;
|
|
558
|
+
try {
|
|
559
|
+
device = this.storage.getDevice(newDeviceId);
|
|
622
560
|
}
|
|
623
|
-
|
|
624
|
-
startPingInterval() {
|
|
625
|
-
const interval = this.options.pingInterval ?? DEFAULT_PING_INTERVAL;
|
|
626
|
-
if (interval <= 0)
|
|
561
|
+
catch {
|
|
627
562
|
return;
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
563
|
+
} // Storage may be closed during shutdown
|
|
564
|
+
if (!device)
|
|
565
|
+
return;
|
|
566
|
+
const summary = {
|
|
567
|
+
id: device.id,
|
|
568
|
+
name: device.name,
|
|
569
|
+
role: device.role,
|
|
570
|
+
kind: device.kind ?? undefined,
|
|
571
|
+
publicKey: device.publicKey ?? undefined,
|
|
572
|
+
encryptionKey: device.encryptionKey ?? undefined,
|
|
573
|
+
online: true,
|
|
574
|
+
};
|
|
575
|
+
const msg = JSON.stringify({ type: 'device_joined', device: summary });
|
|
576
|
+
let userDevices;
|
|
577
|
+
try {
|
|
578
|
+
userDevices = this.storage.getDevicesByUser(userId);
|
|
579
|
+
}
|
|
580
|
+
catch {
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
for (const d of userDevices) {
|
|
584
|
+
if (d.id === newDeviceId)
|
|
585
|
+
continue;
|
|
586
|
+
const ws = this.connections.get(d.id);
|
|
587
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
588
|
+
try {
|
|
589
|
+
ws.send(msg);
|
|
636
590
|
}
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
591
|
+
catch { /* best effort */ }
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
broadcastDeviceLeft(userId, leftDeviceId) {
|
|
596
|
+
const msg = JSON.stringify({ type: 'device_left', deviceId: leftDeviceId });
|
|
597
|
+
let userDevices;
|
|
598
|
+
try {
|
|
599
|
+
userDevices = this.storage.getDevicesByUser(userId);
|
|
600
|
+
}
|
|
601
|
+
catch {
|
|
602
|
+
return;
|
|
603
|
+
} // Storage may be closed during shutdown
|
|
604
|
+
for (const d of userDevices) {
|
|
605
|
+
if (d.id === leftDeviceId)
|
|
606
|
+
continue;
|
|
607
|
+
const ws = this.connections.get(d.id);
|
|
608
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
609
|
+
try {
|
|
610
|
+
ws.send(msg);
|
|
640
611
|
}
|
|
612
|
+
catch { /* best effort */ }
|
|
641
613
|
}
|
|
642
|
-
}
|
|
614
|
+
}
|
|
643
615
|
}
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
616
|
+
// --- Helpers ---
|
|
617
|
+
getDeviceSummaries(userId) {
|
|
618
|
+
const stored = this.storage.getDevicesByUser(userId);
|
|
619
|
+
return stored.map(d => ({
|
|
620
|
+
id: d.id,
|
|
621
|
+
name: d.name,
|
|
622
|
+
role: d.role,
|
|
623
|
+
kind: d.kind ?? undefined,
|
|
624
|
+
publicKey: d.publicKey ?? undefined,
|
|
625
|
+
encryptionKey: d.encryptionKey ?? undefined,
|
|
626
|
+
online: this.connections.has(d.id),
|
|
627
|
+
}));
|
|
649
628
|
}
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
if (this.recentClientMsgIds.size > 10_000) {
|
|
655
|
-
this.recentClientMsgIds.clear(); // safety valve
|
|
656
|
-
}
|
|
657
|
-
this.recentClientMsgIds.add(id);
|
|
658
|
-
return false;
|
|
629
|
+
sendError(ws, message) {
|
|
630
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
631
|
+
ws.send(JSON.stringify({ type: 'server_error', message }));
|
|
632
|
+
}
|
|
659
633
|
}
|
|
660
|
-
/**
|
|
661
|
-
* Close the server and all connections.
|
|
662
|
-
*/
|
|
663
634
|
close() {
|
|
664
635
|
if (this.pingTimer) {
|
|
665
636
|
clearInterval(this.pingTimer);
|
|
666
637
|
this.pingTimer = null;
|
|
667
638
|
}
|
|
668
|
-
if (this.dedupCleanupTimer) {
|
|
669
|
-
clearInterval(this.dedupCleanupTimer);
|
|
670
|
-
this.dedupCleanupTimer = null;
|
|
671
|
-
}
|
|
672
639
|
for (const ws of this.clients.keys()) {
|
|
673
640
|
ws.close();
|
|
674
641
|
}
|