@walkhi/code-relax 0.1.0-beta.18 → 0.1.0-beta.19

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.
@@ -13,7 +13,7 @@ if (!['--schema', '--import', '--check'].includes(command) || !process.env.RELAY
13
13
  const config = JSON.parse(fs.readFileSync(process.env.RELAY_MYSQL_CONFIG_FILE, 'utf8'));
14
14
  const db = await mysql.createConnection({ ...config, dateStrings: true, timezone: 'Z', multipleStatements: false });
15
15
  const sqlTime = value => value.replace('T', ' ').replace(/Z$/, '');
16
- const migrations = ['001_relay_core', '002_email_accounts', '003_relay_usage', '004_relay_usage_actions'];
16
+ const migrations = ['001_relay_core', '002_email_accounts', '003_relay_usage', '004_relay_usage_actions', '005_relay_usage_diagnostics'];
17
17
  try {
18
18
  if (command === '--schema') {
19
19
  const [existing] = await db.query("SHOW TABLES LIKE 'schema_migrations'");
@@ -42,6 +42,7 @@ try {
42
42
  if (versions.some(row => row.version === '002_email_accounts')) names.push('email_login_challenges', 'user_sessions', 'email_send_limits');
43
43
  if (versions.some(row => row.version === '003_relay_usage')) names.push('relay_admin_emails', 'relay_usage_settings', 'relay_usage_totals', 'relay_usage_computer_totals', 'relay_usage_entitlements', 'relay_usage_budgets', 'relay_usage_computer_budgets', 'relay_usage_sessions');
44
44
  if (versions.some(row => row.version === '004_relay_usage_actions')) names.push('relay_usage_actions');
45
+ if (versions.some(row => row.version === '005_relay_usage_diagnostics')) names.push('relay_usage_diagnostics');
45
46
  const counts = {};
46
47
  for (const name of names) { const [rows] = await db.query(`SELECT COUNT(*) AS count FROM ${name}`); counts[name] = rows[0].count; }
47
48
  if (process.env.RELAY_STATE_FILE && process.env.RELAY_ADMIN_STATE_FILE) {
@@ -52,6 +52,7 @@ if (revokeIndex >= 0) {
52
52
  console.log('Computer and phone authorization revoked'); process.exit(0);
53
53
  }
54
54
  const relay = await startSelfRelay({ port, hostKey, stateFile, mysqlConfigFile, accountConfigFile, debugAllowlistFile, adminStateFile, maxRelaySessions,
55
+ usageDiagnosticKeyFile: process.env.RELAY_USAGE_DIAGNOSTIC_KEY_FILE,
55
56
  publicRelayUrl: process.env.RELAY_PUBLIC_URL || '', appDownloadUrl: process.env.RELAY_APP_DOWNLOAD_URL || '',
56
57
  log: entry => console.log(JSON.stringify({ time: new Date().toISOString(), ...entry })) });
57
58
  console.log(JSON.stringify({ service: 'codex-self-relay', listen: relay.url, maxConnections: 32, maxRelaySessions,
@@ -25,7 +25,7 @@ export async function startAdminServer({ port = 45851, relayUrl = 'http://127.0.
25
25
  if (request.method === 'GET' && target.pathname === '/admin/usage') {
26
26
  reply(response, 200, adminUsagePage(), 'text/html; charset=utf-8'); return;
27
27
  }
28
- if (request.method === 'GET' && ['/admin/api/status', '/admin/api/session', '/admin/api/usage'].includes(target.pathname)) {
28
+ if (request.method === 'GET' && ['/admin/api/status', '/admin/api/session', '/admin/api/usage', '/admin/api/usage-diagnostics'].includes(target.pathname)) {
29
29
  try {
30
30
  const upstream = await fetch(new URL(target.pathname + target.search, relayOrigin), {
31
31
  signal: AbortSignal.timeout(3000), headers: { Authorization: request.headers.authorization || '' } });
@@ -38,7 +38,7 @@ export async function startAdminServer({ port = 45851, relayUrl = 'http://127.0.
38
38
  }
39
39
  return;
40
40
  }
41
- if (!target.pathname.startsWith('/admin/api/') || !['PUT', 'DELETE'].includes(request.method || '')) {
41
+ if (!target.pathname.startsWith('/admin/api/') || !['PUT', 'DELETE', 'POST'].includes(request.method || '')) {
42
42
  reply(response, 404, { error: 'Not found' }); return;
43
43
  }
44
44
  try {
@@ -228,8 +228,9 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
228
228
  const request = decode(payload, false);
229
229
  if (!['request', 'subscribe', 'unsubscribe'].includes(request.type)) throw new Error('Unsupported LAN message');
230
230
  if (request.type === 'request' && request.method === 'POST' && Number.isSafeInteger(request.id)) {
231
- const existing = /^\/api\/threads\/([^/?]+)\/(?:messages|queue)$/.exec(request.path || '');
232
- const created = /^\/api\/projects\/[^/?]+\/threads$/.test(request.path || '');
231
+ const path = (request.path || '').split('?')[0];
232
+ const existing = /^\/api\/threads\/([^/?]+)\/(?:messages|queue)$/.exec(path);
233
+ const created = /^\/api\/projects\/[^/?]+\/threads$/.test(path);
233
234
  if (existing || created) {
234
235
  const threadId = existing ? decodeURIComponent(existing[1]) : null;
235
236
  const messageKey = typeof request.body?.clientUserMessageId === 'string' && request.body.clientUserMessageId.length <= 64
@@ -17,7 +17,7 @@ export async function openRelayMysql(configFile, initialMaxRelaySessions) {
17
17
  namedPlaceholders: false, dateStrings: true, timezone: 'Z', multipleStatements: false });
18
18
  try {
19
19
  const [versions] = await pool.query('SELECT version, sha256 FROM schema_migrations');
20
- const knownVersions = ['001_relay_core', '002_email_accounts', '003_relay_usage', '004_relay_usage_actions'];
20
+ const knownVersions = ['001_relay_core', '002_email_accounts', '003_relay_usage', '004_relay_usage_actions', '005_relay_usage_diagnostics'];
21
21
  if (!versions.some(row => row.version === knownVersions[0]) || versions.length > knownVersions.length) throw new Error('Unexpected relay database schema');
22
22
  for (const row of versions) {
23
23
  if (!knownVersions.includes(row.version)) throw new Error('Unexpected relay database schema');
@@ -1,7 +1,8 @@
1
1
  import http from 'node:http';
2
+ import fs from 'node:fs';
2
3
  import { isIP } from 'node:net';
3
4
  import { deviceStore, validId } from './device-store.mjs';
4
- import { randomUUID } from 'node:crypto';
5
+ import { randomUUID, timingSafeEqual } from 'node:crypto';
5
6
  import { WebSocketServer } from 'ws';
6
7
  import { secret, digest, matches, validSecret, send, decode } from './wire.mjs';
7
8
  import { readDebugAllowlist, debugAllowed } from './debug-allowlist.mjs';
@@ -11,11 +12,13 @@ import { openRelayMysql } from './mysql-state.mjs';
11
12
  import { adminPage } from './admin-page.mjs';
12
13
  import { openEmailAccounts } from './email-accounts.mjs';
13
14
  import { usageMeter, usageReport } from './usage-meter.mjs';
15
+ import { usageDiagnostics } from './usage-diagnostics.mjs';
14
16
 
15
17
  // Always loopback; a TLS reverse proxy supplies the private-trial Internet endpoint.
16
18
  export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlMs = 120000,
17
19
  maxConnections = 32, maxClientsPerHost = 16, maxPayload = 8 * 1024 * 1024, stateFile,
18
- maxRelaySessions = 8, adminStateFile, mysqlConfigFile, accountConfigFile, publicRelayUrl = '', appDownloadUrl = '', debugAllowlistFile, log = () => {} } = {}) {
20
+ maxRelaySessions = 8, adminStateFile, mysqlConfigFile, accountConfigFile, usageDiagnosticKeyFile,
21
+ publicRelayUrl = '', appDownloadUrl = '', debugAllowlistFile, log = () => {} } = {}) {
19
22
  if (!validSecret(hostKey)) throw new Error('Invalid host credential');
20
23
  if (!Number.isSafeInteger(maxClientsPerHost) || maxClientsPerHost < 1 || maxClientsPerHost > 64) throw new Error('Invalid mobile device limit');
21
24
  if (!Number.isSafeInteger(maxRelaySessions) || maxRelaySessions < 1 || maxRelaySessions > 256) throw new Error('Invalid relay session limit');
@@ -29,6 +32,10 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
29
32
  if (accountConfigFile && !database?.schemaVersions.includes('003_relay_usage')) throw new Error('Relay account and usage schema is required');
30
33
  const accounts = accountConfigFile ? openEmailAccounts(database.pool, accountConfigFile) : null;
31
34
  const meter = accounts ? usageMeter(database.pool) : null;
35
+ const diagnostics = database?.schemaVersions.includes('005_relay_usage_diagnostics')
36
+ ? usageDiagnostics(database.pool, error => log({ event: 'usage-diagnostic-error', message: error.message })) : null;
37
+ const diagnosticKey = usageDiagnosticKeyFile ? fs.readFileSync(usageDiagnosticKeyFile, 'utf8').trim() : '';
38
+ if (diagnosticKey && !/^[A-Za-z0-9_-]{43}$/.test(diagnosticKey)) throw new Error('Invalid usage diagnostic key');
32
39
  const store = database?.devices || deviceStore(stateFile);
33
40
  const debugAllowlist = readDebugAllowlist(debugAllowlistFile);
34
41
  const adminState = database?.admin || relayAdminState(adminStateFile, maxRelaySessions);
@@ -147,11 +154,17 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
147
154
  }
148
155
  if (target.pathname.startsWith('/admin/api/')) {
149
156
  const token = /^Bearer ([A-Za-z0-9_-]{43})$/.exec(request.headers.authorization || '')?.[1];
157
+ const diagnosticRoute = target.pathname === '/admin/api/usage-diagnostics' ||
158
+ target.pathname === '/admin/api/usage-diagnostics/drain';
159
+ const keyAccepted = diagnosticRoute && diagnosticKey && token &&
160
+ timingSafeEqual(Buffer.from(token), Buffer.from(diagnosticKey));
150
161
  let email;
151
- try { email = (await accounts.current(token)).email; }
152
- catch { adminReply(response, 401, { error: '请先登录管理员账号' }); return; }
153
- const [allowed] = await database.pool.execute('SELECT email FROM relay_admin_emails WHERE email=?', [email]);
154
- if (!allowed.length) { adminReply(response, 403, { error: '此账号没有后台管理权限' }); return; }
162
+ if (!keyAccepted) {
163
+ try { email = (await accounts.current(token)).email; }
164
+ catch { adminReply(response, 401, { error: '请先登录管理员账号' }); return; }
165
+ const [allowed] = await database.pool.execute('SELECT email FROM relay_admin_emails WHERE email=?', [email]);
166
+ if (!allowed.length) { adminReply(response, 403, { error: '此账号没有后台管理权限' }); return; }
167
+ }
155
168
  if (target.pathname === '/admin/api/session' && request.method === 'GET') {
156
169
  adminReply(response, 200, { email }); return;
157
170
  }
@@ -173,6 +186,17 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
173
186
  }
174
187
  adminReply(response, 200, await usageReport(database.pool, page, user)); return;
175
188
  }
189
+ if (diagnostics && target.pathname === '/admin/api/usage-diagnostics') {
190
+ if (request.method === 'GET') adminReply(response, 200, await diagnostics.read());
191
+ else if (request.method === 'DELETE') adminReply(response, 200, await diagnostics.clear());
192
+ else adminReply(response, 405, { error: 'Unsupported diagnostic request' });
193
+ return;
194
+ }
195
+ if (diagnostics && target.pathname === '/admin/api/usage-diagnostics/drain') {
196
+ if (request.method === 'POST') adminReply(response, 200, await diagnostics.drain());
197
+ else adminReply(response, 405, { error: 'Unsupported diagnostic request' });
198
+ return;
199
+ }
176
200
  if (!['PUT', 'DELETE'].includes(request.method || '') || request.headers['content-type'] !== 'application/json' ||
177
201
  request.headers['x-code-relax-admin'] !== '1') { adminReply(response, 405, { error: 'Unsupported admin request' }); return; }
178
202
  try {
@@ -433,6 +457,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
433
457
  repeat: room.p2pProbeRepeat, signals: 0, probeStarted: 0, outbound: Promise.resolve(), queuedBytes: 0,
434
458
  relayAdmitted: false, relayExempt: false,
435
459
  capacityExempt: debugAllowed(debugAllowlist, room.id, room.clients.find(client => client.id === clientId)) });
460
+ diagnostics?.record('client', userId ? 'account-linked' : 'anonymous');
436
461
  send(ws, { type: 'paired', protocolVersion: 7, room: room.id, clientId, revision: room.revision, ...(credential ? { credential } : {}) });
437
462
  send(ws, { type: 'computer-info', hostName: room.hostName });
438
463
  if (room.lanCandidates?.length) send(ws, { type: 'lan-candidates', candidates: room.lanCandidates });
@@ -453,27 +478,41 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
453
478
  const armKey = `${message.session}:${message.requestId}`;
454
479
  const connection = [...room.connections.values()].find(item => item.session === message.session);
455
480
  if (message.type === 'usage-arm') {
481
+ diagnostics?.record('arm', connection?.userId ? 'account-linked' : 'anonymous');
456
482
  if (!connection?.userId || !Number.isSafeInteger(message.requestId) || message.requestId < 1 ||
457
- (message.threadId !== null && (!plainText(message.threadId, 128, false)))) return;
483
+ (message.threadId !== null && (!plainText(message.threadId, 128, false)))) {
484
+ diagnostics?.record('arm-rejected', !connection?.userId ? 'account-missing' : 'invalid-request');
485
+ return;
486
+ }
458
487
  for (const [key, arm] of room.usageArms) if (Date.now() - arm.at > 120000) room.usageArms.delete(key);
459
488
  if (room.usageArms.size >= 256) return;
460
489
  room.usageArms.set(armKey, { userId: connection.userId,
461
490
  clientId: [...room.connections.entries()].find(([, item]) => item === connection)?.[0],
462
491
  session: message.session, at: Date.now() });
492
+ diagnostics?.record('arm-accepted', 'ready');
463
493
  return;
464
494
  }
465
- if (message.type === 'usage-cancel') { room.usageArms.delete(armKey); return; }
495
+ if (message.type === 'usage-cancel') {
496
+ diagnostics?.record('message-cancelled', room.usageArms.has(armKey) ? 'armed' : 'arm-missing');
497
+ room.usageArms.delete(armKey); return;
498
+ }
466
499
  if (message.type === 'usage-message') {
467
500
  const arm = room.usageArms.get(armKey);
501
+ diagnostics?.record('message', arm ? 'armed' : 'arm-missing');
468
502
  room.usageArms.delete(armKey);
469
- if (!arm || Date.now() - arm.at > 120000 || !plainText(message.messageKey, 128, false)) return;
503
+ if (!arm || Date.now() - arm.at > 120000 || !plainText(message.messageKey, 128, false)) {
504
+ diagnostics?.record('message-rejected', !arm ? 'arm-missing' : 'expired-or-invalid');
505
+ return;
506
+ }
470
507
  try {
471
508
  await meter.action(arm, room.id, arm.clientId, 'message', message.messageKey);
509
+ diagnostics?.record('message-counted', 'confirmed');
472
510
  if (message.turnKind === 'start' && plainText(message.threadId, 80, false) && plainText(message.turnId, 80, false)) {
473
511
  const key = `${message.threadId}:${message.turnId}`;
474
512
  if (key.length <= 128 && !room.usageTasks.has(key)) {
475
513
  const task = { userId: arm.userId, clientId: arm.clientId };
476
514
  await meter.action(task, room.id, arm.clientId, 'turn', key);
515
+ diagnostics?.record('turn-counted', 'confirmed');
477
516
  if (message.observerReady === true) {
478
517
  await meter.pulse(task, true, room.id, arm.clientId, key);
479
518
  if (message.completed) await meter.pulse(task, false, room.id, arm.clientId);
@@ -481,7 +520,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
481
520
  }
482
521
  }
483
522
  }
484
- } catch (error) { log({ event: 'usage-error', message: error.message }); }
523
+ } catch (error) { diagnostics?.record('message-error', 'database'); log({ event: 'usage-error', message: error.message }); }
485
524
  return;
486
525
  }
487
526
  if (!['turn/started', 'turn/completed', 'heartbeat', 'resume'].includes(message.method) ||
@@ -492,12 +531,14 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
492
531
  try {
493
532
  if (message.method === 'resume' && !task) {
494
533
  task = await meter.turnOrigin(room.id, key);
495
- if (!task) return;
534
+ if (!task) { diagnostics?.record('turn-resume', 'origin-missing'); return; }
496
535
  await meter.pulse(task, true, room.id, task.clientId, `${key}:${randomUUID()}`);
536
+ diagnostics?.record('turn-resume', 'confirmed');
497
537
  room.usageTasks.set(key, task);
498
538
  } else if (task && message.method === 'heartbeat') await meter.pulse(task, true, room.id, task.clientId);
499
539
  else if (task && message.method === 'turn/completed') {
500
540
  await meter.pulse(task, false, room.id, task.clientId);
541
+ diagnostics?.record('turn-completed', 'confirmed');
501
542
  room.usageTasks.delete(key);
502
543
  }
503
544
  } catch (error) { log({ event: 'usage-error', message: error.message }); }
@@ -0,0 +1,46 @@
1
+ const MAX_BATCH = 200;
2
+
3
+ export function usageDiagnostics(pool, onError = () => {}) {
4
+ let tail = Promise.resolve();
5
+ function serialize(action) {
6
+ const result = tail.then(action);
7
+ tail = result.catch(error => { onError(error); });
8
+ return result;
9
+ }
10
+ function record(event, detail) {
11
+ if (!/^[a-z-]{1,32}$/.test(event) || !/^[a-z-]{1,64}$/.test(detail)) throw new Error('Invalid usage diagnostic');
12
+ void serialize(async () => {
13
+ await pool.execute('INSERT INTO relay_usage_diagnostics (created_at,event,detail) VALUES (UTC_TIMESTAMP(3),?,?)', [event, detail]);
14
+ await pool.execute('DELETE FROM relay_usage_diagnostics WHERE created_at < UTC_TIMESTAMP(3) - INTERVAL 1 DAY');
15
+ }).catch(() => {});
16
+ }
17
+ async function read() {
18
+ return serialize(async () => {
19
+ const [rows] = await pool.query(`SELECT id,created_at,event,detail FROM relay_usage_diagnostics
20
+ ORDER BY id LIMIT ${MAX_BATCH + 1}`);
21
+ return { rows: rows.slice(0, MAX_BATCH), hasMore: rows.length > MAX_BATCH };
22
+ });
23
+ }
24
+ async function clear() {
25
+ return serialize(async () => {
26
+ const [result] = await pool.query('DELETE FROM relay_usage_diagnostics');
27
+ return { cleared: result.affectedRows };
28
+ });
29
+ }
30
+ async function drain() {
31
+ return serialize(async () => {
32
+ const db = await pool.getConnection();
33
+ try {
34
+ await db.beginTransaction();
35
+ const [rows] = await db.query(`SELECT id,created_at,event,detail FROM relay_usage_diagnostics
36
+ ORDER BY id LIMIT ${MAX_BATCH + 1} FOR UPDATE`);
37
+ const selected = rows.slice(0, MAX_BATCH);
38
+ if (selected.length) await db.execute('DELETE FROM relay_usage_diagnostics WHERE id <= ?', [selected.at(-1).id]);
39
+ await db.commit();
40
+ return { rows: selected, hasMore: rows.length > MAX_BATCH };
41
+ } catch (error) { await db.rollback(); throw error; }
42
+ finally { db.release(); }
43
+ });
44
+ }
45
+ return { record, read, clear, drain };
46
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@walkhi/code-relax",
3
- "version": "0.1.0-beta.18",
3
+ "version": "0.1.0-beta.19",
4
4
  "license": "UNLICENSED",
5
5
  "type": "module",
6
6
  "scripts": {