@kraki/head 0.1.0 → 0.4.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 +415 -436
- package/dist/server.js.map +1 -1
- package/dist/storage.d.ts +6 -86
- package/dist/storage.js +49 -301
- 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,451 @@ 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
|
-
|
|
227
|
+
if (msg.type === 'update_preferences') {
|
|
228
|
+
if (state.userId) {
|
|
229
|
+
const prefs = msg.preferences;
|
|
230
|
+
if (prefs && typeof prefs === 'object') {
|
|
231
|
+
this.storage.updatePreferences(state.userId, prefs);
|
|
232
|
+
ws.send(JSON.stringify({ type: 'preferences_updated', preferences: prefs }));
|
|
233
|
+
}
|
|
234
|
+
}
|
|
282
235
|
return;
|
|
283
236
|
}
|
|
284
|
-
if (msg.type === '
|
|
285
|
-
|
|
237
|
+
if (msg.type === 'ping') {
|
|
238
|
+
ws.send(JSON.stringify({ type: 'pong' }));
|
|
286
239
|
return;
|
|
287
240
|
}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
this.router.handleMessage(state.deviceId, msg);
|
|
241
|
+
if (msg.type === 'pong') {
|
|
242
|
+
return; // silently accept pong responses
|
|
291
243
|
}
|
|
244
|
+
this.sendError(ws, `Unknown message type: ${msg.type}`);
|
|
292
245
|
}
|
|
293
|
-
|
|
246
|
+
// --- Routing ---
|
|
247
|
+
handleUnicast(ws, state, msg) {
|
|
294
248
|
const logger = getLogger();
|
|
295
|
-
|
|
296
|
-
|
|
249
|
+
const senderUserId = state.userId;
|
|
250
|
+
if (!senderUserId || !state.deviceId)
|
|
251
|
+
return;
|
|
252
|
+
// Verify target belongs to same user
|
|
253
|
+
const targetDevice = this.storage.getDevice(msg.to);
|
|
254
|
+
if (!targetDevice || targetDevice.userId !== senderUserId) {
|
|
255
|
+
logger.warn('Unicast rejected: target not found or wrong user', { from: state.deviceId, to: msg.to });
|
|
256
|
+
ws.send(JSON.stringify({ type: 'server_error', message: 'Target device not found or offline', ref: msg.ref }));
|
|
297
257
|
return;
|
|
298
258
|
}
|
|
299
|
-
|
|
300
|
-
|
|
259
|
+
const targetWs = this.connections.get(msg.to);
|
|
260
|
+
if (!targetWs) {
|
|
261
|
+
logger.debug('Unicast target offline', { to: msg.to });
|
|
262
|
+
ws.send(JSON.stringify({ type: 'server_error', message: 'Target device not found or offline', ref: msg.ref }));
|
|
301
263
|
return;
|
|
302
264
|
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
}));
|
|
265
|
+
try {
|
|
266
|
+
if (targetWs.readyState === WebSocket.OPEN) {
|
|
267
|
+
targetWs.send(JSON.stringify(msg));
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
logger.warn('Unicast send failed, removing connection', { to: msg.to });
|
|
272
|
+
this.removeConnection(msg.to);
|
|
273
|
+
}
|
|
313
274
|
}
|
|
314
|
-
|
|
275
|
+
handleBroadcast(state, msg) {
|
|
315
276
|
const logger = getLogger();
|
|
316
|
-
|
|
317
|
-
|
|
277
|
+
const senderUserId = state.userId;
|
|
278
|
+
const senderDeviceId = state.deviceId;
|
|
279
|
+
if (!senderUserId || !senderDeviceId)
|
|
318
280
|
return;
|
|
281
|
+
// Find all other connected devices for this user
|
|
282
|
+
const userDevices = this.storage.getDevicesByUser(senderUserId);
|
|
283
|
+
for (const device of userDevices) {
|
|
284
|
+
if (device.id === senderDeviceId)
|
|
285
|
+
continue;
|
|
286
|
+
const targetWs = this.connections.get(device.id);
|
|
287
|
+
if (!targetWs)
|
|
288
|
+
continue;
|
|
289
|
+
try {
|
|
290
|
+
if (targetWs.readyState === WebSocket.OPEN) {
|
|
291
|
+
targetWs.send(JSON.stringify(msg));
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
logger.warn('Broadcast send failed, removing connection', { to: device.id });
|
|
296
|
+
this.removeConnection(device.id);
|
|
297
|
+
}
|
|
319
298
|
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
299
|
+
}
|
|
300
|
+
removeConnection(deviceId) {
|
|
301
|
+
const ws = this.connections.get(deviceId);
|
|
302
|
+
this.connections.delete(deviceId);
|
|
303
|
+
this.userByDevice.delete(deviceId);
|
|
304
|
+
if (ws) {
|
|
305
|
+
try {
|
|
306
|
+
ws.close();
|
|
307
|
+
}
|
|
308
|
+
catch { /* best effort */ }
|
|
323
309
|
}
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
310
|
+
}
|
|
311
|
+
sendAuthError(ws, code, message) {
|
|
312
|
+
ws.send(JSON.stringify({ type: 'auth_error', code, message }));
|
|
313
|
+
}
|
|
314
|
+
// --- Auth ---
|
|
315
|
+
async handleAuth(ws, state, msg) {
|
|
316
|
+
const logger = getLogger();
|
|
317
|
+
const { auth } = msg;
|
|
318
|
+
switch (auth.method) {
|
|
319
|
+
case 'pairing': {
|
|
320
|
+
this.handlePairingAuth(ws, state, auth.token, msg);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
case 'challenge': {
|
|
324
|
+
const device = this.storage.getDevice(auth.deviceId);
|
|
325
|
+
if (device && device.publicKey) {
|
|
326
|
+
const nonce = randomBytes(32).toString('hex');
|
|
327
|
+
state.pendingNonce = nonce;
|
|
328
|
+
state.pendingDeviceId = auth.deviceId;
|
|
329
|
+
state.pendingDeviceInfo = { encryptionKey: msg.device.encryptionKey };
|
|
330
|
+
state.pendingAuthMethod = 'challenge';
|
|
331
|
+
logger.debug('Issuing auth challenge', { deviceId: auth.deviceId });
|
|
332
|
+
ws.send(JSON.stringify({ type: 'auth_challenge', nonce }));
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
this.sendAuthError(ws, 'unknown_device', 'Unknown device');
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
case 'github_token': {
|
|
339
|
+
const provider = this.getAuthProviderForMode('github');
|
|
340
|
+
const result = await provider.authenticate({ token: auth.token, ip: state.ip });
|
|
341
|
+
if (!result.ok) {
|
|
342
|
+
logger.warn('Auth rejected', { method: 'github_token', ip: state.ip, reason: result.message });
|
|
343
|
+
this.sendAuthError(ws, 'auth_rejected', result.message);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
this.completeAuth(ws, state, result.user, msg, 'github_token');
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
case 'github_oauth': {
|
|
350
|
+
const provider = this.getAuthProviderForMode('github');
|
|
351
|
+
const result = await provider.authenticate({ githubCode: auth.code, ip: state.ip });
|
|
352
|
+
if (!result.ok) {
|
|
353
|
+
logger.warn('Auth rejected', { method: 'github_oauth', ip: state.ip, reason: result.message });
|
|
354
|
+
this.sendAuthError(ws, 'auth_rejected', result.message);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
this.completeAuth(ws, state, result.user, msg, 'github_oauth');
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
case 'apikey': {
|
|
361
|
+
const provider = this.getAuthProviderForMode('apikey');
|
|
362
|
+
const result = await provider.authenticate({ token: auth.key, ip: state.ip });
|
|
363
|
+
if (!result.ok) {
|
|
364
|
+
logger.warn('Auth rejected', { method: 'apikey', ip: state.ip, reason: result.message });
|
|
365
|
+
this.sendAuthError(ws, 'auth_rejected', result.message);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
this.completeAuth(ws, state, result.user, msg, 'apikey');
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
case 'open': {
|
|
372
|
+
const provider = this.getAuthProviderForMode('open');
|
|
373
|
+
const result = await provider.authenticate({ token: auth.sharedKey, ip: state.ip });
|
|
374
|
+
if (!result.ok) {
|
|
375
|
+
logger.warn('Auth rejected', { method: 'open', ip: state.ip, reason: result.message });
|
|
376
|
+
this.sendAuthError(ws, 'auth_rejected', result.message);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
this.completeAuth(ws, state, result.user, msg, 'open');
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
default: {
|
|
383
|
+
const method = auth.method;
|
|
384
|
+
this.sendAuthError(ws, 'unknown_auth_method', `Unknown auth method: ${method}`);
|
|
385
|
+
}
|
|
328
386
|
}
|
|
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
387
|
}
|
|
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) {
|
|
388
|
+
handlePairingAuth(ws, state, pairingToken, msg) {
|
|
342
389
|
const logger = getLogger();
|
|
343
390
|
if (!(this.options.pairingEnabled ?? true)) {
|
|
344
|
-
this.
|
|
391
|
+
this.sendAuthError(ws, 'pairing_disabled', 'Pairing is disabled.');
|
|
345
392
|
return;
|
|
346
393
|
}
|
|
347
|
-
|
|
348
|
-
|
|
394
|
+
const tokenData = this.pairingTokens.get(pairingToken);
|
|
395
|
+
if (!tokenData || tokenData.expiresAt < Date.now()) {
|
|
396
|
+
if (tokenData)
|
|
397
|
+
this.pairingTokens.delete(pairingToken);
|
|
398
|
+
logger.warn('Pairing auth failed: invalid or expired token', { ip: state.ip });
|
|
399
|
+
this.sendAuthError(ws, 'invalid_pairing_token', 'Invalid or expired pairing token');
|
|
349
400
|
return;
|
|
350
401
|
}
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
ws.send(JSON.stringify({ type: 'auth_error', message: authResult.message }));
|
|
402
|
+
// Consume token (single-use)
|
|
403
|
+
this.pairingTokens.delete(pairingToken);
|
|
404
|
+
const user = this.storage.getUser(tokenData.userId);
|
|
405
|
+
if (!user) {
|
|
406
|
+
this.sendAuthError(ws, 'user_not_found', 'User not found');
|
|
357
407
|
return;
|
|
358
408
|
}
|
|
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
|
-
}));
|
|
409
|
+
const authUser = { id: user.userId, login: user.username, provider: user.provider, email: user.email };
|
|
410
|
+
this.completeAuth(ws, state, authUser, msg, 'pairing');
|
|
370
411
|
}
|
|
371
|
-
|
|
412
|
+
handleChallengeResponse(ws, state, msg) {
|
|
372
413
|
const logger = getLogger();
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
414
|
+
const deviceId = state.pendingDeviceId;
|
|
415
|
+
const nonce = state.pendingNonce;
|
|
416
|
+
const pendingInfo = state.pendingDeviceInfo;
|
|
417
|
+
state.pendingNonce = undefined;
|
|
418
|
+
state.pendingDeviceId = undefined;
|
|
419
|
+
state.pendingDeviceInfo = undefined;
|
|
420
|
+
if (!deviceId || !nonce) {
|
|
421
|
+
this.sendAuthError(ws, 'no_pending_challenge', 'No pending challenge');
|
|
376
422
|
return;
|
|
377
423
|
}
|
|
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
|
-
}
|
|
424
|
+
const device = this.storage.getDevice(deviceId);
|
|
425
|
+
if (!device || !device.publicKey) {
|
|
426
|
+
this.sendAuthError(ws, 'device_not_found', 'Device not found');
|
|
427
|
+
return;
|
|
394
428
|
}
|
|
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 }));
|
|
429
|
+
const publicKeyPem = importPublicKey(device.publicKey);
|
|
430
|
+
const valid = verifySignature(nonce, msg.signature, publicKeyPem);
|
|
431
|
+
if (!valid) {
|
|
432
|
+
logger.warn('Challenge-response auth failed', { deviceId, ip: state.ip });
|
|
433
|
+
this.sendAuthError(ws, 'invalid_signature', 'Invalid signature');
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
// Update encryption key if provided
|
|
437
|
+
const encryptionKey = pendingInfo?.encryptionKey ?? device.encryptionKey ?? undefined;
|
|
438
|
+
this.storage.upsertDevice(deviceId, device.userId, device.name, device.role, device.kind ?? undefined, device.publicKey ?? undefined, encryptionKey);
|
|
439
|
+
const user = this.storage.getUser(device.userId);
|
|
440
|
+
if (!user) {
|
|
441
|
+
this.sendAuthError(ws, 'user_not_found', 'User not found');
|
|
409
442
|
return;
|
|
410
443
|
}
|
|
411
|
-
|
|
412
|
-
|
|
444
|
+
// Register connection
|
|
445
|
+
state.authenticated = true;
|
|
446
|
+
state.deviceId = deviceId;
|
|
447
|
+
state.userId = user.userId;
|
|
448
|
+
this.connections.set(deviceId, ws);
|
|
449
|
+
this.userByDevice.set(deviceId, user.userId);
|
|
450
|
+
logger.info('Device authenticated via challenge-response', { deviceId, ip: state.ip });
|
|
451
|
+
const fullUser = this.storage.getUser(user.userId);
|
|
452
|
+
ws.send(JSON.stringify({
|
|
453
|
+
type: 'auth_ok',
|
|
454
|
+
deviceId,
|
|
455
|
+
authMethod: 'challenge',
|
|
456
|
+
user: { id: user.userId, login: user.username, provider: user.provider, preferences: fullUser?.preferences },
|
|
457
|
+
devices: this.getDeviceSummaries(user.userId),
|
|
458
|
+
githubClientId: this.getGitHubClientId(),
|
|
459
|
+
relayVersion: this.options.version,
|
|
460
|
+
}));
|
|
461
|
+
// Notify other connected devices about the reconnected device
|
|
462
|
+
this.broadcastDeviceJoined(user.userId, deviceId);
|
|
463
|
+
}
|
|
464
|
+
completeAuth(ws, state, user, msg, authMethod) {
|
|
465
|
+
const logger = getLogger();
|
|
466
|
+
// Persist user
|
|
467
|
+
this.storage.upsertUser(user.id, user.login, user.provider, user.email);
|
|
468
|
+
// Register device
|
|
469
|
+
const deviceId = msg.device.deviceId ?? `dev_${uuid().slice(0, 12)}`;
|
|
413
470
|
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
|
-
});
|
|
471
|
+
this.storage.upsertDevice(deviceId, user.id, msg.device.name, msg.device.role, msg.device.kind, msg.device.publicKey, msg.device.encryptionKey);
|
|
428
472
|
}
|
|
429
473
|
catch (err) {
|
|
430
474
|
logger.warn('Device registration failed', { ip: state.ip, error: err.message });
|
|
431
|
-
|
|
475
|
+
this.sendAuthError(ws, 'device_registration_failed', err.message);
|
|
432
476
|
return;
|
|
433
477
|
}
|
|
434
478
|
state.authenticated = true;
|
|
435
479
|
state.deviceId = deviceId;
|
|
436
|
-
state.
|
|
480
|
+
state.userId = user.id;
|
|
481
|
+
this.connections.set(deviceId, ws);
|
|
482
|
+
this.userByDevice.set(deviceId, user.id);
|
|
437
483
|
logger.info('Device authenticated', {
|
|
438
484
|
deviceId,
|
|
439
485
|
name: msg.device.name,
|
|
440
486
|
role: msg.device.role,
|
|
441
|
-
user:
|
|
487
|
+
user: user.login,
|
|
442
488
|
ip: state.ip,
|
|
443
489
|
});
|
|
490
|
+
const fullUser = this.storage.getUser(user.id);
|
|
444
491
|
ws.send(JSON.stringify({
|
|
445
492
|
type: 'auth_ok',
|
|
446
|
-
channel: channelId,
|
|
447
493
|
deviceId,
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
...this.getGitHubClientId(),
|
|
494
|
+
authMethod,
|
|
495
|
+
user: { id: user.id, login: user.login, provider: user.provider, preferences: fullUser?.preferences },
|
|
496
|
+
devices: this.getDeviceSummaries(user.id),
|
|
497
|
+
githubClientId: this.getGitHubClientId(),
|
|
498
|
+
relayVersion: this.options.version,
|
|
454
499
|
}));
|
|
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
|
-
});
|
|
500
|
+
// Notify other connected devices about the new device
|
|
501
|
+
this.broadcastDeviceJoined(user.id, deviceId);
|
|
472
502
|
}
|
|
473
|
-
|
|
503
|
+
// --- Pairing tokens (in-memory) ---
|
|
504
|
+
handleCreatePairingToken(ws, state) {
|
|
474
505
|
const logger = getLogger();
|
|
475
506
|
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' }));
|
|
507
|
+
this.sendError(ws, 'Pairing is disabled on this relay');
|
|
484
508
|
return;
|
|
485
509
|
}
|
|
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 }));
|
|
510
|
+
if (!state.userId) {
|
|
511
|
+
this.sendError(ws, 'Not authenticated');
|
|
506
512
|
return;
|
|
507
513
|
}
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
514
|
+
const token = `pt_${randomBytes(32).toString('hex')}`;
|
|
515
|
+
const ttl = this.options.pairingTtl ?? 300;
|
|
516
|
+
this.pairingTokens.set(token, {
|
|
517
|
+
userId: state.userId,
|
|
518
|
+
expiresAt: Date.now() + ttl * 1000,
|
|
519
|
+
});
|
|
520
|
+
logger.info('Pairing token created', { userId: state.userId, ttl });
|
|
512
521
|
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(),
|
|
522
|
+
type: 'pairing_token_created',
|
|
523
|
+
token,
|
|
524
|
+
expiresIn: ttl,
|
|
522
525
|
}));
|
|
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
526
|
}
|
|
540
|
-
|
|
527
|
+
async handleRequestPairingToken(ws, state, msg) {
|
|
541
528
|
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' }));
|
|
529
|
+
if (!(this.options.pairingEnabled ?? true)) {
|
|
530
|
+
this.sendError(ws, 'Pairing is disabled on this relay');
|
|
550
531
|
return;
|
|
551
532
|
}
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
ws.send(JSON.stringify({ type: 'auth_error', message: 'Device not found' }));
|
|
533
|
+
if (!msg.token) {
|
|
534
|
+
this.sendError(ws, 'Token required for pairing request');
|
|
555
535
|
return;
|
|
556
536
|
}
|
|
557
|
-
//
|
|
558
|
-
|
|
559
|
-
const
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
return;
|
|
537
|
+
// Try all configured auth providers until one succeeds
|
|
538
|
+
let authResult = { ok: false, message: 'No auth provider accepted the token' };
|
|
539
|
+
for (const provider of (this.options.authProviders?.values() ?? [])) {
|
|
540
|
+
authResult = await provider.authenticate({ token: msg.token, ip: state.ip });
|
|
541
|
+
if (authResult.ok)
|
|
542
|
+
break;
|
|
564
543
|
}
|
|
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
|
-
});
|
|
544
|
+
// Fall back to legacy single provider if no authProviders map
|
|
545
|
+
if (!authResult.ok && this.options.authProvider && !this.options.authProviders?.size) {
|
|
546
|
+
authResult = await this.options.authProvider.authenticate({ token: msg.token, ip: state.ip });
|
|
582
547
|
}
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
ws.send(JSON.stringify({ type: 'auth_error', message: err.message }));
|
|
548
|
+
if (!authResult.ok) {
|
|
549
|
+
this.sendAuthError(ws, 'auth_rejected', authResult.message);
|
|
586
550
|
return;
|
|
587
551
|
}
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
552
|
+
// Ensure user exists
|
|
553
|
+
this.storage.upsertUser(authResult.user.id, authResult.user.login, authResult.user.provider, authResult.user.email);
|
|
554
|
+
const token = `pt_${randomBytes(32).toString('hex')}`;
|
|
555
|
+
const ttl = this.options.pairingTtl ?? 300;
|
|
556
|
+
this.pairingTokens.set(token, {
|
|
557
|
+
userId: authResult.user.id,
|
|
558
|
+
expiresAt: Date.now() + ttl * 1000,
|
|
559
|
+
});
|
|
560
|
+
logger.info('Pairing token created (one-shot)', { userId: authResult.user.id, ttl });
|
|
592
561
|
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(),
|
|
562
|
+
type: 'pairing_token_created',
|
|
563
|
+
token,
|
|
564
|
+
expiresIn: ttl,
|
|
602
565
|
}));
|
|
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
566
|
}
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
567
|
+
// --- Device presence notifications ---
|
|
568
|
+
broadcastDeviceJoined(userId, newDeviceId) {
|
|
569
|
+
let device;
|
|
570
|
+
try {
|
|
571
|
+
device = this.storage.getDevice(newDeviceId);
|
|
622
572
|
}
|
|
623
|
-
|
|
624
|
-
startPingInterval() {
|
|
625
|
-
const interval = this.options.pingInterval ?? DEFAULT_PING_INTERVAL;
|
|
626
|
-
if (interval <= 0)
|
|
573
|
+
catch {
|
|
627
574
|
return;
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
575
|
+
} // Storage may be closed during shutdown
|
|
576
|
+
if (!device)
|
|
577
|
+
return;
|
|
578
|
+
const summary = {
|
|
579
|
+
id: device.id,
|
|
580
|
+
name: device.name,
|
|
581
|
+
role: device.role,
|
|
582
|
+
kind: device.kind ?? undefined,
|
|
583
|
+
publicKey: device.publicKey ?? undefined,
|
|
584
|
+
encryptionKey: device.encryptionKey ?? undefined,
|
|
585
|
+
online: true,
|
|
586
|
+
};
|
|
587
|
+
const msg = JSON.stringify({ type: 'device_joined', device: summary });
|
|
588
|
+
let userDevices;
|
|
589
|
+
try {
|
|
590
|
+
userDevices = this.storage.getDevicesByUser(userId);
|
|
591
|
+
}
|
|
592
|
+
catch {
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
for (const d of userDevices) {
|
|
596
|
+
if (d.id === newDeviceId)
|
|
597
|
+
continue;
|
|
598
|
+
const ws = this.connections.get(d.id);
|
|
599
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
600
|
+
try {
|
|
601
|
+
ws.send(msg);
|
|
636
602
|
}
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
603
|
+
catch { /* best effort */ }
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
broadcastDeviceLeft(userId, leftDeviceId) {
|
|
608
|
+
const msg = JSON.stringify({ type: 'device_left', deviceId: leftDeviceId });
|
|
609
|
+
let userDevices;
|
|
610
|
+
try {
|
|
611
|
+
userDevices = this.storage.getDevicesByUser(userId);
|
|
612
|
+
}
|
|
613
|
+
catch {
|
|
614
|
+
return;
|
|
615
|
+
} // Storage may be closed during shutdown
|
|
616
|
+
for (const d of userDevices) {
|
|
617
|
+
if (d.id === leftDeviceId)
|
|
618
|
+
continue;
|
|
619
|
+
const ws = this.connections.get(d.id);
|
|
620
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
621
|
+
try {
|
|
622
|
+
ws.send(msg);
|
|
640
623
|
}
|
|
624
|
+
catch { /* best effort */ }
|
|
641
625
|
}
|
|
642
|
-
}
|
|
626
|
+
}
|
|
643
627
|
}
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
628
|
+
// --- Helpers ---
|
|
629
|
+
getDeviceSummaries(userId) {
|
|
630
|
+
const stored = this.storage.getDevicesByUser(userId);
|
|
631
|
+
return stored.map(d => ({
|
|
632
|
+
id: d.id,
|
|
633
|
+
name: d.name,
|
|
634
|
+
role: d.role,
|
|
635
|
+
kind: d.kind ?? undefined,
|
|
636
|
+
publicKey: d.publicKey ?? undefined,
|
|
637
|
+
encryptionKey: d.encryptionKey ?? undefined,
|
|
638
|
+
online: this.connections.has(d.id),
|
|
639
|
+
}));
|
|
649
640
|
}
|
|
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;
|
|
641
|
+
sendError(ws, message) {
|
|
642
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
643
|
+
ws.send(JSON.stringify({ type: 'server_error', message }));
|
|
644
|
+
}
|
|
659
645
|
}
|
|
660
|
-
/**
|
|
661
|
-
* Close the server and all connections.
|
|
662
|
-
*/
|
|
663
646
|
close() {
|
|
664
647
|
if (this.pingTimer) {
|
|
665
648
|
clearInterval(this.pingTimer);
|
|
666
649
|
this.pingTimer = null;
|
|
667
650
|
}
|
|
668
|
-
if (this.dedupCleanupTimer) {
|
|
669
|
-
clearInterval(this.dedupCleanupTimer);
|
|
670
|
-
this.dedupCleanupTimer = null;
|
|
671
|
-
}
|
|
672
651
|
for (const ws of this.clients.keys()) {
|
|
673
652
|
ws.close();
|
|
674
653
|
}
|