@walkhi/code-relax 0.1.0-beta.11 → 0.1.0-beta.12

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.
@@ -1,4 +1,5 @@
1
- import http from 'node:http';
1
+ import http from 'node:http';
2
+ import { isIP } from 'node:net';
2
3
  import { deviceStore, validId } from './device-store.mjs';
3
4
  import { randomUUID } from 'node:crypto';
4
5
  import { WebSocketServer } from 'ws';
@@ -6,12 +7,14 @@ import { secret, digest, matches, validSecret, send, decode } from './wire.mjs';
6
7
  import { readDebugAllowlist, debugAllowed } from './debug-allowlist.mjs';
7
8
  import { validProbeSignal } from './p2p-probe.mjs';
8
9
  import { relayAdminState } from './admin-state.mjs';
10
+ import { openRelayMysql } from './mysql-state.mjs';
9
11
  import { adminPage } from './admin-page.mjs';
12
+ import { openEmailAccounts } from './email-accounts.mjs';
10
13
 
11
14
  // Always loopback; a TLS reverse proxy supplies the private-trial Internet endpoint.
12
15
  export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlMs = 120000,
13
16
  maxConnections = 32, maxClientsPerHost = 16, maxPayload = 8 * 1024 * 1024, stateFile,
14
- maxRelaySessions = 8, adminStateFile, publicRelayUrl = '', appDownloadUrl = '', debugAllowlistFile, log = () => {} } = {}) {
17
+ maxRelaySessions = 8, adminStateFile, mysqlConfigFile, accountConfigFile, publicRelayUrl = '', appDownloadUrl = '', debugAllowlistFile, log = () => {} } = {}) {
15
18
  if (!validSecret(hostKey)) throw new Error('Invalid host credential');
16
19
  if (!Number.isSafeInteger(maxClientsPerHost) || maxClientsPerHost < 1 || maxClientsPerHost > 64) throw new Error('Invalid mobile device limit');
17
20
  if (!Number.isSafeInteger(maxRelaySessions) || maxRelaySessions < 1 || maxRelaySessions > 256) throw new Error('Invalid relay session limit');
@@ -21,24 +24,34 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
21
24
  const nativeOrigins = publicRelayUrl ? ['http:', 'https:'].map(protocol => `${protocol}//${new URL(publicRelayUrl).host}`) : [];
22
25
  if (appDownloadUrl && !/^https:\/\/[^\s]+$/.test(appDownloadUrl)) throw new Error('Invalid app download URL');
23
26
  const hostHash = digest(hostKey), rooms = new Map();
24
- const store = deviceStore(stateFile);
27
+ const database = mysqlConfigFile ? await openRelayMysql(mysqlConfigFile, maxRelaySessions) : null;
28
+ if (accountConfigFile && !database?.schemaVersions.includes('002_email_accounts')) throw new Error('Email account schema is required');
29
+ const accounts = accountConfigFile ? openEmailAccounts(database.pool, accountConfigFile) : null;
30
+ const store = database?.devices || deviceStore(stateFile);
25
31
  const debugAllowlist = readDebugAllowlist(debugAllowlistFile);
26
- const adminState = relayAdminState(adminStateFile, maxRelaySessions);
32
+ const adminState = database?.admin || relayAdminState(adminStateFile, maxRelaySessions);
27
33
  let activeRelaySessions = 0;
28
34
  for (const record of store.entries()) rooms.set(record.id, { ...record, durable: true, connections: new Map(), observers: new Map(), uploadClients: new Map() });
35
+ let commitTail = Promise.resolve();
29
36
  function commit(room, changes) {
37
+ const operation = commitTail.then(async () => {
38
+ changes = typeof changes === 'function' ? changes(room) : changes;
30
39
  const next = { ...room, ...changes, updatedAt: new Date().toISOString() };
31
40
  if (room.durable) {
32
41
  const { id, hostHash, clients, revision, status, createdAt, updatedAt, officialComputer } = next;
33
- store.save({ id, hostHash, clients, revision, status, createdAt, updatedAt, ...(officialComputer ? { officialComputer } : {}) });
42
+ await store.save({ id, hostHash, clients, revision, status, createdAt, updatedAt, ...(officialComputer ? { officialComputer } : {}) });
34
43
  }
35
- Object.assign(room, changes, { updatedAt: next.updatedAt });
44
+ Object.assign(room, changes, { updatedAt: next.updatedAt });
45
+ });
46
+ commitTail = operation.catch(() => {});
47
+ return operation;
36
48
  }
37
49
  function invitation(room) {
38
50
  const code = secret(); room.pairingHash = digest(code); room.expires = Date.now() + pairingTtlMs;
39
51
  return { code, expires: room.expires };
40
52
  }
41
53
  let listenUrl = '';
54
+ let adminTail = Promise.resolve();
42
55
  function relayUrl(request) {
43
56
  if (publicRelayUrl) return relayPublicAddress(publicRelayUrl);
44
57
  const host = request.headers.host || new URL(listenUrl).host;
@@ -52,7 +65,14 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
52
65
  const server = http.createServer((request, response) => {
53
66
  const target = new URL(request.url || '/', `http://${request.headers.host || 'localhost'}`);
54
67
  if (target.pathname === '/admin' || target.pathname.startsWith('/admin/')) {
55
- void handleAdmin(request, response, target); return;
68
+ adminTail = adminTail.then(() => handleAdmin(request, response, target)).catch(() => {
69
+ if (!response.headersSent) adminReply(response, 500, { error: 'Admin operation failed' });
70
+ });
71
+ return;
72
+ }
73
+ if (accounts && target.pathname.startsWith('/account/')) {
74
+ void handleAccount(request, response, target).catch(() => accountReply(response, 500, { error: 'Account operation failed' }));
75
+ return;
56
76
  }
57
77
  if (request.method === 'GET' && target.pathname === '/announcement') {
58
78
  const body = JSON.stringify({ announcement: adminState.read().announcement });
@@ -83,6 +103,41 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
83
103
  'X-Content-Type-Options': 'nosniff', 'Content-Security-Policy': "default-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'" });
84
104
  response.end(value);
85
105
  }
106
+ function accountReply(response, status, body) {
107
+ if (response.headersSent || response.destroyed) return;
108
+ const value = JSON.stringify(body);
109
+ response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(value),
110
+ 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' });
111
+ response.end(value);
112
+ }
113
+ async function handleAccount(request, response, target) {
114
+ try {
115
+ if (target.pathname === '/account/config' && request.method === 'GET') {
116
+ accountReply(response, 200, { captchaAppId: accounts.captchaAppId }); return;
117
+ }
118
+ const bearer = /^Bearer ([A-Za-z0-9_-]{43})$/.exec(request.headers.authorization || '')?.[1];
119
+ if (target.pathname === '/account/me' && request.method === 'GET') {
120
+ accountReply(response, 200, { user: await accounts.current(bearer) }); return;
121
+ }
122
+ if (target.pathname === '/account/logout' && request.method === 'POST') {
123
+ accountReply(response, 200, await accounts.logout(bearer)); return;
124
+ }
125
+ if (request.method !== 'POST' || request.headers['content-type'] !== 'application/json') {
126
+ accountReply(response, 404, { error: 'Account route not found' }); return;
127
+ }
128
+ const body = JSON.parse(await readBody(request, 4096));
129
+ if (!body || typeof body !== 'object' || Array.isArray(body)) { accountReply(response, 400, { error: 'Invalid request body' }); return; }
130
+ if (target.pathname === '/account/email/request') {
131
+ const ip = request.headers['x-real-ip'] || request.socket.remoteAddress || '';
132
+ if (typeof ip !== 'string' || !isIP(ip)) { accountReply(response, 400, { error: 'Invalid client address' }); return; }
133
+ accountReply(response, 202, await accounts.requestCode(body, ip));
134
+ } else if (target.pathname === '/account/email/verify') {
135
+ accountReply(response, 200, await accounts.verifyCode(body));
136
+ } else accountReply(response, 404, { error: 'Account route not found' });
137
+ } catch (failure) {
138
+ accountReply(response, failure.status || 503, { error: failure.status ? failure.message : 'Account service unavailable' });
139
+ }
140
+ }
86
141
  async function handleAdmin(request, response, target) {
87
142
  if (request.method === 'GET' && target.pathname === '/admin') {
88
143
  adminReply(response, 200, adminPage(), 'text/html; charset=utf-8'); return;
@@ -101,10 +156,10 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
101
156
  try {
102
157
  const body = request.method === 'DELETE' ? {} : JSON.parse(await readBody(request, 4096));
103
158
  if (target.pathname === '/admin/api/settings' && request.method === 'PUT') {
104
- const state = adminState.setMaxRelaySessions(body.maxRelaySessions);
159
+ const state = await adminState.setMaxRelaySessions(body.maxRelaySessions);
105
160
  log({ event: 'admin-relay-limit-updated', maxRelaySessions: state.maxRelaySessions });
106
161
  } else if (target.pathname === '/admin/api/announcements' && request.method === 'PUT') {
107
- const state = adminState.createAnnouncement(body.title, body.message, body.publish === true);
162
+ const state = await adminState.createAnnouncement(body.title, body.message, body.publish === true);
108
163
  if (body.publish === true) {
109
164
  broadcastAnnouncement(state.announcement);
110
165
  log({ event: 'admin-announcement-published', announcementId: state.announcement.id });
@@ -114,17 +169,17 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
114
169
  const id = parts[0];
115
170
  if (!/^[\w-]{16,128}$/.test(id)) { adminReply(response, 404, { error: 'Admin route not found' }); return; }
116
171
  if (parts.length === 1 && request.method === 'PUT') {
117
- adminState.updateDraft(id, body.title, body.message);
172
+ await adminState.updateDraft(id, body.title, body.message);
118
173
  log({ event: 'admin-announcement-draft-updated', announcementId: id });
119
174
  } else if (parts.length === 1 && request.method === 'DELETE') {
120
- adminState.deleteAnnouncement(id);
175
+ await adminState.deleteAnnouncement(id);
121
176
  log({ event: 'admin-announcement-deleted', announcementId: id });
122
177
  } else if (parts.length === 2 && parts[1] === 'publish' && request.method === 'PUT') {
123
- const state = adminState.publishDraft(id, body.title, body.message);
178
+ const state = await adminState.publishDraft(id, body.title, body.message);
124
179
  broadcastAnnouncement(state.announcement);
125
180
  log({ event: 'admin-announcement-published', announcementId: id });
126
181
  } else if (parts.length === 2 && parts[1] === 'withdraw' && request.method === 'PUT') {
127
- const state = adminState.withdraw(id);
182
+ const state = await adminState.withdraw(id);
128
183
  broadcastAnnouncement(state.announcement);
129
184
  log({ event: 'admin-announcement-withdrawn', announcementId: id });
130
185
  } else { adminReply(response, 404, { error: 'Admin route not found' }); return; }
@@ -214,8 +269,10 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
214
269
  if (room.uploadHost) send(room.uploadHost, { type: 'file-close', session });
215
270
  }
216
271
  });
217
- ws.on('message', (bytes, binary) => {
218
- try {
272
+ let messageTail = Promise.resolve();
273
+ ws.on('message', (bytes, binary) => {
274
+ messageTail = messageTail.then(async () => {
275
+ try {
219
276
  const message = decode(bytes, binary);
220
277
  if (!role) {
221
278
  if (message.type === 'file-host') {
@@ -262,7 +319,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
262
319
  room = { id: durable ? message.deviceId : randomUUID(), durable,
263
320
  revision: 0, status: 'active', clients: [], connections: new Map(), observers: new Map(), uploadClients: new Map(), createdAt: now, updatedAt: now,
264
321
  ...(durable ? { hostHash: digest(message.deviceSecret) } : {}) };
265
- commit(room, {}); rooms.set(room.id, room);
322
+ await commit(room, {}); rooms.set(room.id, room);
266
323
  }
267
324
  const oldHost = room.host;
268
325
  room.observers ||= new Map();
@@ -286,7 +343,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
286
343
  room = rooms.get(message.room);
287
344
  if (!room || room.status !== 'active') return fail();
288
345
  let credential;
289
- if (message.type === 'pair' && room.pairingHash && Date.now() < room.expires && matches(message.code, room.pairingHash)) {
346
+ if (message.type === 'pair' && !room.pairingInProgress && room.pairingHash && Date.now() < room.expires && matches(message.code, room.pairingHash)) {
290
347
  if (!room.host) return fail(4410, 'Computer offline; authorization retained');
291
348
  credential = message.credential || secret();
292
349
  clientId = message.clientId || (!room.durable ? randomUUID() : undefined);
@@ -295,8 +352,11 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
295
352
  if (!existing && room.clients.length >= maxClientsPerHost) return fail(4429, 'Mobile device limit reached');
296
353
  const now = new Date().toISOString();
297
354
  const client = { id: clientId, hash: digest(credential), createdAt: existing?.createdAt || now, updatedAt: now };
298
- commit(room, { clients: [...room.clients.filter(item => item.id !== clientId), client], revision: room.revision + 1 });
299
- room.pairingHash = undefined;
355
+ room.pairingInProgress = true;
356
+ try {
357
+ await commit(room, current => ({ clients: [...current.clients.filter(item => item.id !== clientId), client], revision: current.revision + 1 }));
358
+ room.pairingHash = undefined;
359
+ } finally { room.pairingInProgress = false; }
300
360
  send(room.host, { type: 'authorization-changed', clients: room.clients, revision: room.revision });
301
361
  } else {
302
362
  clientId = message.clientId;
@@ -355,7 +415,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
355
415
  send(ws, { type: 'pair-code', commandId: message.commandId, room: room.id, ...invitation(room) }); return;
356
416
  }
357
417
  if (message.type === 'revoke') {
358
- commit(room, { clients: [], revision: room.revision + 1 });
418
+ await commit(room, current => ({ clients: [], revision: current.revision + 1 }));
359
419
  room.pairingHash = undefined;
360
420
  for (const connection of room.connections.values()) {
361
421
  releaseRelay(connection);
@@ -398,7 +458,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
398
458
  send(room.uploadHost, { type: 'file-frame', session, payload: message.payload });
399
459
  } else if (role === 'observer' && room.observers.get(clientId)?.ws === ws && message.type === 'official-computer') {
400
460
  const computer = normalizeOfficialComputer(message);
401
- commit(room, { officialComputer: computer });
461
+ await commit(room, { officialComputer: computer });
402
462
  send(ws, { type: 'official-computer-saved', computer });
403
463
  } else if (role === 'client' && room.connections.get(clientId)?.ws === ws && message.type === 'p2p-probe') {
404
464
  const connection = room.connections.get(clientId);
@@ -428,17 +488,19 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
428
488
  }
429
489
  send(room.host, { type: 'data', session, payload: message.payload });
430
490
  } else fail();
431
- } catch (error) { log({ event: 'request-error', role, message: 'Relay operation failed' }); ws.close(1011, 'Relay operation failed'); }
432
- });
491
+ } catch (error) { log({ event: 'request-error', role, message: 'Relay operation failed' }); ws.close(1011, 'Relay operation failed'); }
492
+ }).catch(() => ws.close(1011, 'Relay operation failed'));
493
+ });
433
494
  });
434
495
  await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, '127.0.0.1', resolve); });
435
496
  listenUrl = `ws://127.0.0.1:${server.address().port}/relay`;
436
497
  return {
437
498
  url: listenUrl, hostKey,
438
499
  async close() {
439
- for (const ws of wss.clients) ws.terminate();
440
- await new Promise(resolve => wss.close(resolve));
441
- await new Promise(resolve => server.close(resolve));
500
+ for (const ws of wss.clients) ws.terminate();
501
+ await new Promise(resolve => wss.close(resolve));
502
+ await new Promise(resolve => server.close(resolve));
503
+ if (database) await database.close();
442
504
  },
443
505
  };
444
506
  }