@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/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,439 @@ 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);
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
- // Handle producer, consumer, and encrypted messages
289
- if (state.deviceId) {
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
- handleCreatePairingToken(ws, state) {
236
+ // --- Routing ---
237
+ handleUnicast(ws, state, msg) {
294
238
  const logger = getLogger();
295
- if (!(this.options.pairingEnabled ?? true)) {
296
- this.sendError(ws, 'Pairing is disabled on this relay');
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
- if (!state.channelId) {
300
- this.sendError(ws, 'Not authenticated');
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
- 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
- }));
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
- handleDeleteSession(ws, state, msg) {
265
+ handleBroadcast(state, msg) {
315
266
  const logger = getLogger();
316
- if (!state.channelId) {
317
- this.sendError(ws, 'Not authenticated');
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
- if (!msg.sessionId || typeof msg.sessionId !== 'string') {
321
- this.sendError(ws, 'sessionId required for delete_session');
322
- return;
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
- const session = this.cm.getStorage().getSessionById(msg.sessionId);
325
- if (!session || session.channelId !== state.channelId) {
326
- this.sendError(ws, 'Session not found');
327
- return;
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.sendError(ws, 'Pairing is disabled on this relay');
381
+ this.sendAuthError(ws, 'pairing_disabled', 'Pairing is disabled.');
345
382
  return;
346
383
  }
347
- if (!msg.token) {
348
- this.sendError(ws, 'Token required for pairing request');
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
- 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 }));
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 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
- }));
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
- async handleAuth(ws, state, msg) {
402
+ handleChallengeResponse(ws, state, msg) {
372
403
  const logger = getLogger();
373
- // Try pairing token auth first
374
- if (msg.pairingToken) {
375
- this.handlePairingAuth(ws, state, msg);
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
- // 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
- }
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
- // 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 }));
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
- const channelId = this.cm.getOrCreateChannel(authResult.user);
412
- let deviceId;
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 = 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
- });
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
- ws.send(JSON.stringify({ type: 'auth_error', message: err.message }));
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.channelId = channelId;
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: authResult.user.login,
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
- 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(),
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.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
- });
488
+ // Notify other connected devices about the new device
489
+ this.broadcastDeviceJoined(user.id, deviceId);
472
490
  }
473
- handlePairingAuth(ws, state, msg) {
491
+ // --- Pairing tokens (in-memory) ---
492
+ handleCreatePairingToken(ws, state) {
474
493
  const logger = getLogger();
475
494
  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' }));
495
+ this.sendError(ws, 'Pairing is disabled on this relay');
484
496
  return;
485
497
  }
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 }));
498
+ if (!state.userId) {
499
+ this.sendError(ws, 'Not authenticated');
506
500
  return;
507
501
  }
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 });
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: '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(),
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
- handleChallengeResponse(ws, state, msg) {
515
+ async handleRequestPairingToken(ws, state, msg) {
541
516
  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' }));
517
+ if (!(this.options.pairingEnabled ?? true)) {
518
+ this.sendError(ws, 'Pairing is disabled on this relay');
550
519
  return;
551
520
  }
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' }));
521
+ if (!msg.token) {
522
+ this.sendError(ws, 'Token required for pairing request');
555
523
  return;
556
524
  }
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;
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
- // 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
- });
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
- catch (err) {
584
- logger.warn('Device registration failed (challenge)', { deviceId, error: err.message });
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
- state.authenticated = true;
589
- state.deviceId = registeredId;
590
- state.channelId = device.channelId;
591
- logger.info('Device authenticated via challenge-response', { deviceId, ip: state.ip });
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: '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(),
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
- sendError(ws, message) {
620
- if (ws.readyState === WebSocket.OPEN) {
621
- ws.send(JSON.stringify({ type: 'server_error', message }));
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
- 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;
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
- state.alive = false;
638
- if (ws.readyState === WebSocket.OPEN) {
639
- ws.ping();
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
- }, interval);
614
+ }
643
615
  }
644
- startDedupCleanup() {
645
- // Clear dedup set periodically and bound max size
646
- this.dedupCleanupTimer = setInterval(() => {
647
- this.recentClientMsgIds.clear();
648
- }, 5 * 60_000);
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
- /** 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;
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
  }