@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/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
- if (typeof obj.type !== 'string' || obj.type.length === 0)
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
- return true;
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
- if (typeof p.model !== 'string')
56
- return false;
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
- /** Validate encrypted envelope */
62
- function isValidEncrypted(msg) {
63
- return typeof msg.iv === 'string' && typeof msg.ciphertext === 'string'
64
- && typeof msg.tag === 'string' && typeof msg.keys === 'object' && msg.keys !== null;
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
- cm;
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
- /** Dedup: track recently seen client message IDs to prevent duplicates */
74
- recentClientMsgIds = new Set();
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
- /** Resolve the auth provider (multi-provider or legacy single). */
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
- if (ghProvider?.oauthConfigured) {
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
- /** Resolve the channel owner's user info for auth_ok. */
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, alive: true };
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 && state.channelId) {
187
- const device = this.cm.disconnectDevice(state.deviceId);
188
- if (device) {
189
- logger.info('Device disconnected', { deviceId: state.deviceId, name: device.name });
190
- this.router.broadcastNotice(state.channelId, {
191
- type: 'head_notice',
192
- event: 'device_offline',
193
- data: { deviceId: state.deviceId },
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
- // Handle ping/pong
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
- // Must auth first
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
- authModes: this.getAuthModes(),
227
- e2e: this.options.e2e,
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
- // Validate type-specific messages
249
- if (msg.type === 'encrypted' && !isValidEncrypted(msg)) {
250
- this.sendError(ws, 'Invalid encrypted message: iv, ciphertext, tag, keys required');
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
- // Handle control messages
258
- if (msg.type === 'replay') {
259
- const replay = msg;
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
- this.router.replay(state.deviceId, replay.afterSeq, replay.sessionId);
215
+ }
216
+ this.handleUnicast(ws, state, msg);
263
217
  return;
264
218
  }
265
- if (msg.type === 'mark_read') {
266
- if (!state.channelId)
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 === 'create_pairing_token') {
281
- this.handleCreatePairingToken(ws, state);
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 === 'delete_session') {
285
- this.handleDeleteSession(ws, state, msg);
237
+ if (msg.type === 'ping') {
238
+ ws.send(JSON.stringify({ type: 'pong' }));
286
239
  return;
287
240
  }
288
- // Handle producer, consumer, and encrypted messages
289
- if (state.deviceId) {
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
- handleCreatePairingToken(ws, state) {
246
+ // --- Routing ---
247
+ handleUnicast(ws, state, msg) {
294
248
  const logger = getLogger();
295
- if (!(this.options.pairingEnabled ?? true)) {
296
- this.sendError(ws, 'Pairing is disabled on this relay');
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
- if (!state.channelId) {
300
- this.sendError(ws, 'Not authenticated');
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
- const token = `pt_${randomBytes(32).toString('hex')}`;
304
- const ttl = this.options.pairingTtl ?? 300;
305
- const expiresAt = new Date(Date.now() + ttl * 1000).toISOString();
306
- this.cm.getStorage().createPairingToken(token, state.channelId, expiresAt);
307
- logger.info('Pairing token created', { channelId: state.channelId, ttl });
308
- ws.send(JSON.stringify({
309
- type: 'pairing_token_created',
310
- token,
311
- expiresIn: ttl,
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
- handleDeleteSession(ws, state, msg) {
275
+ handleBroadcast(state, msg) {
315
276
  const logger = getLogger();
316
- if (!state.channelId) {
317
- this.sendError(ws, 'Not authenticated');
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
- if (!msg.sessionId || typeof msg.sessionId !== 'string') {
321
- this.sendError(ws, 'sessionId required for delete_session');
322
- return;
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
- const session = this.cm.getStorage().getSessionById(msg.sessionId);
325
- if (!session || session.channelId !== state.channelId) {
326
- this.sendError(ws, 'Session not found');
327
- return;
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.sendError(ws, 'Pairing is disabled on this relay');
391
+ this.sendAuthError(ws, 'pairing_disabled', 'Pairing is disabled.');
345
392
  return;
346
393
  }
347
- if (!msg.token) {
348
- this.sendError(ws, 'Token required for pairing request');
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
- const authResult = await this.getAuthProvider().authenticate({
352
- token: msg.token,
353
- ip: state.ip,
354
- });
355
- if (!authResult.ok) {
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 channelId = this.cm.getOrCreateChannel(authResult.user);
360
- const token = `pt_${randomBytes(32).toString('hex')}`;
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
- async handleAuth(ws, state, msg) {
412
+ handleChallengeResponse(ws, state, msg) {
372
413
  const logger = getLogger();
373
- // Try pairing token auth first
374
- if (msg.pairingToken) {
375
- this.handlePairingAuth(ws, state, msg);
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
- // Try challenge-response for returning devices with a known deviceId + publicKey
379
- if (msg.device.deviceId && !msg.token && !msg.channelKey && !msg.githubCode) {
380
- const device = this.cm.getStorage().getDevice(msg.device.deviceId);
381
- if (device && device.publicKey) {
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
- // Route to GitHub provider when an OAuth code is provided
396
- const githubCode = msg.githubCode;
397
- const authProvider = githubCode
398
- ? this.getAuthProviderForMode('github')
399
- : this.getAuthProvider();
400
- const authResult = await authProvider.authenticate({
401
- token: msg.token,
402
- channelKey: msg.channelKey,
403
- githubCode,
404
- ip: state.ip,
405
- });
406
- if (!authResult.ok) {
407
- logger.warn('Auth rejected', { ip: state.ip, reason: authResult.message });
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
- const channelId = this.cm.getOrCreateChannel(authResult.user);
412
- let deviceId;
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 = this.cm.registerDevice({
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
- ws.send(JSON.stringify({ type: 'auth_error', message: err.message }));
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.channelId = channelId;
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: authResult.user.login,
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
- e2e: this.options.e2e,
449
- devices: this.cm.getDeviceSummaries(channelId),
450
- sessions: this.cm.getSessionSummaries(channelId),
451
- readState: this.cm.getStorage().getReadState(channelId),
452
- user: this.getChannelOwnerUser(channelId),
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.router.broadcastNotice(channelId, {
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
- handlePairingAuth(ws, state, msg) {
503
+ // --- Pairing tokens (in-memory) ---
504
+ handleCreatePairingToken(ws, state) {
474
505
  const logger = getLogger();
475
506
  if (!(this.options.pairingEnabled ?? true)) {
476
- ws.send(JSON.stringify({ type: 'auth_error', message: 'Pairing is disabled. Use OAuth to authenticate.' }));
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
- let deviceId;
487
- try {
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
- state.authenticated = true;
509
- state.deviceId = deviceId;
510
- state.channelId = channelId;
511
- logger.info('Device paired via token', { deviceId, name: msg.device.name, ip: state.ip });
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: 'auth_ok',
514
- channel: channelId,
515
- deviceId,
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
- handleChallengeResponse(ws, state, msg) {
527
+ async handleRequestPairingToken(ws, state, msg) {
541
528
  const logger = getLogger();
542
- const deviceId = state.pendingDeviceId;
543
- const nonce = state.pendingNonce;
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
- const device = this.cm.getStorage().getDevice(deviceId);
553
- if (!device || !device.publicKey) {
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
- // Verify signature
558
- const publicKeyPem = importPublicKey(device.publicKey);
559
- const valid = verifySignature(nonce, msg.signature, publicKeyPem);
560
- if (!valid) {
561
- logger.warn('Challenge-response auth failed', { deviceId, ip: state.ip });
562
- ws.send(JSON.stringify({ type: 'auth_error', message: 'Invalid signature' }));
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
- // Use encryptionKey from the auth message (may be new/updated)
566
- const encryptionKey = pendingInfo?.encryptionKey ?? device.encryptionKey ?? undefined;
567
- let registeredId;
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
- catch (err) {
584
- logger.warn('Device registration failed (challenge)', { deviceId, error: err.message });
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
- state.authenticated = true;
589
- state.deviceId = registeredId;
590
- state.channelId = device.channelId;
591
- logger.info('Device authenticated via challenge-response', { deviceId, ip: state.ip });
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: 'auth_ok',
594
- channel: device.channelId,
595
- deviceId: registeredId,
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
- sendError(ws, message) {
620
- if (ws.readyState === WebSocket.OPEN) {
621
- ws.send(JSON.stringify({ type: 'server_error', message }));
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
- const timeout = this.options.pongTimeout ?? DEFAULT_PONG_TIMEOUT;
629
- const logger = getLogger();
630
- this.pingTimer = setInterval(() => {
631
- for (const [ws, state] of this.clients) {
632
- if (!state.alive) {
633
- logger.warn('Device ping timeout, terminating', { deviceId: state.deviceId, ip: state.ip });
634
- ws.terminate();
635
- continue;
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
- state.alive = false;
638
- if (ws.readyState === WebSocket.OPEN) {
639
- ws.ping();
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
- }, interval);
626
+ }
643
627
  }
644
- startDedupCleanup() {
645
- // Clear dedup set periodically and bound max size
646
- this.dedupCleanupTimer = setInterval(() => {
647
- this.recentClientMsgIds.clear();
648
- }, 5 * 60_000);
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
- /** Guard against unbounded dedup set growth */
651
- trackClientMsgId(id) {
652
- if (this.recentClientMsgIds.has(id))
653
- return true; // duplicate
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
  }