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

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.
@@ -5,7 +5,9 @@ $ErrorActionPreference = 'Stop'
5
5
  $taskName = 'Code Relax Bridge'
6
6
  $taskSource = '@walkhi/code-relax'
7
7
  $taskPath = '\' + $taskName
8
- $arguments = '"' + $Entry + '" start --json'
8
+ $launcher = Join-Path $PSScriptRoot 'resident-start.js'
9
+ $wscript = Join-Path $env:WINDIR 'System32\wscript.exe'
10
+ $arguments = '//B //Nologo "' + $launcher + '" "' + $NodePath + '" "' + $Entry + '"'
9
11
  $currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
10
12
 
11
13
  $scheduler = New-Object -ComObject 'Schedule.Service'
@@ -30,7 +32,8 @@ if ($Action -ne 'status' -and $legacyExists) {
30
32
  }
31
33
 
32
34
  if ($Action -eq 'enable') {
33
- if (-not (Test-Path -LiteralPath $NodePath) -or -not (Test-Path -LiteralPath $Entry)) { throw 'Installed runtime is missing.' }
35
+ if (-not (Test-Path -LiteralPath $NodePath) -or -not (Test-Path -LiteralPath $Entry) -or
36
+ -not (Test-Path -LiteralPath $launcher) -or -not (Test-Path -LiteralPath $wscript)) { throw 'Installed runtime is missing.' }
34
37
  $definition = $scheduler.NewTask(0)
35
38
  $definition.RegistrationInfo.Description = 'Start the Code Relax Bridge when this user signs in.'
36
39
  $definition.RegistrationInfo.Source = $taskSource
@@ -48,7 +51,7 @@ if ($Action -eq 'enable') {
48
51
  $trigger.UserId = $currentUser
49
52
  $trigger.Enabled = $true
50
53
  $exec = $definition.Actions.Create(0) # TASK_ACTION_EXEC
51
- $exec.Path = $NodePath
54
+ $exec.Path = $wscript
52
55
  $exec.Arguments = $arguments
53
56
  $exec.WorkingDirectory = $env:USERPROFILE
54
57
  [void]$root.RegisterTaskDefinition($taskName, $definition, 6, $null, $null, 3, $null) # TASK_CREATE_OR_UPDATE
@@ -2,19 +2,20 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { randomUUID } from 'node:crypto';
4
4
 
5
- export function relayAdminState(file, initialMaxRelaySessions) {
6
- let value = { version: 3, maxRelaySessions: initialMaxRelaySessions, announcement: null, announcements: [] };
5
+ export function relayAdminState(file, initialMaxRelaySessions, { initial, persist } = {}) {
6
+ let value = initial ? validate(initial) : { version: 3, maxRelaySessions: initialMaxRelaySessions, announcement: null, announcements: [] };
7
7
  if (file && fs.existsSync(file)) value = validate(JSON.parse(fs.readFileSync(file, 'utf8')));
8
8
 
9
- function save(next) {
10
- value = validate(next);
11
- if (!file) return value;
9
+ async function save(next) {
10
+ const validated = validate(next);
11
+ if (persist) await persist(validated);
12
+ if (!file) { value = validated; return value; }
12
13
  fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
13
14
  const temporary = `${file}.${randomUUID()}.tmp`;
14
15
  let descriptor;
15
16
  try {
16
17
  descriptor = fs.openSync(temporary, 'wx', 0o600);
17
- fs.writeFileSync(descriptor, JSON.stringify(value));
18
+ fs.writeFileSync(descriptor, JSON.stringify(validated));
18
19
  fs.fsyncSync(descriptor); fs.closeSync(descriptor); descriptor = undefined;
19
20
  fs.renameSync(temporary, file);
20
21
  if (process.platform !== 'win32') {
@@ -25,6 +26,7 @@ export function relayAdminState(file, initialMaxRelaySessions) {
25
26
  if (descriptor !== undefined) fs.closeSync(descriptor);
26
27
  fs.rmSync(temporary, { force: true });
27
28
  }
29
+ value = validated;
28
30
  return value;
29
31
  }
30
32
 
@@ -0,0 +1,125 @@
1
+ import fs from 'node:fs';
2
+ import { createHash, createHmac, randomBytes, randomInt, randomUUID, timingSafeEqual } from 'node:crypto';
3
+ import { tencentAccountProvider } from '../../relay-admin/tencent-account-provider.mjs';
4
+
5
+ const sqlTime = value => value.replace('T', ' ').replace(/Z$/, '');
6
+ const isoTime = value => value.replace(' ', 'T') + 'Z';
7
+ const error = (status, message) => Object.assign(new Error(message), { status });
8
+ const tokenHash = token => createHash('sha256').update(token).digest('hex');
9
+ const normalizeEmail = value => {
10
+ if (typeof value !== 'string') throw error(400, 'Invalid email address');
11
+ const email = value.trim().toLowerCase();
12
+ if (email.length > 254 || !/^[a-z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-z0-9-]+(?:\.[a-z0-9-]+)+$/.test(email)) {
13
+ throw error(400, 'Invalid email address');
14
+ }
15
+ return email;
16
+ };
17
+
18
+ export function openEmailAccounts(pool, configFile, providerOverride) {
19
+ const config = JSON.parse(fs.readFileSync(configFile, 'utf8'));
20
+ if (![config.secretId, config.secretKey, config.captchaAppSecretKey, config.fromEmailAddress, config.sesRegion]
21
+ .every(value => typeof value === 'string' && value.length > 0) ||
22
+ !Number.isSafeInteger(config.captchaAppId) || config.captchaAppId < 1 ||
23
+ !Number.isSafeInteger(config.emailTemplateId) || config.emailTemplateId < 1 ||
24
+ typeof config.codePepper !== 'string' || config.codePepper.length < 32) {
25
+ throw new Error('Incomplete relay account configuration');
26
+ }
27
+ const provider = providerOverride || tencentAccountProvider(config);
28
+ const codeHash = (email, code) => createHmac('sha256', config.codePepper).update(`${email}\0${code}`).digest('hex');
29
+ const scopeHash = value => createHmac('sha256', config.codePepper).update(value).digest('hex');
30
+
31
+ async function requestCode({ email: rawEmail, ticket, randstr }, ip) {
32
+ const email = normalizeEmail(rawEmail);
33
+ if (typeof ticket !== 'string' || !ticket || ticket.length > 2048 ||
34
+ typeof randstr !== 'string' || !randstr || randstr.length > 128) throw error(400, 'Invalid human verification');
35
+ if (!ip || ip.length > 64) throw error(400, 'Invalid client address');
36
+ // A cheap pre-check precedes the billable human verification.
37
+ const [recent] = await pool.execute('SELECT sent_at FROM email_login_challenges WHERE email = ? AND sent_at > UTC_TIMESTAMP(3) - INTERVAL 60 SECOND', [email]);
38
+ if (recent.length) throw error(429, 'Please wait before requesting another code');
39
+ const [ipLimit] = await pool.execute(`SELECT request_count FROM email_send_limits
40
+ WHERE scope_hash=? AND window_start>=UTC_TIMESTAMP() - INTERVAL 1 HOUR ORDER BY window_start DESC LIMIT 1`, [scopeHash(`ip:${ip}`)]);
41
+ if (ipLimit[0]?.request_count >= 20) throw error(429, 'Too many email requests');
42
+ if (!await provider.verifyHuman(ticket, randstr, ip)) throw error(403, 'Human verification failed');
43
+ const code = String(randomInt(0, 1000000)).padStart(6, '0');
44
+ const hash = codeHash(email, code);
45
+ const windowStart = sqlTime(new Date(Math.floor(Date.now() / 3600000) * 3600000).toISOString()).slice(0, 19);
46
+ const db = await pool.getConnection();
47
+ try {
48
+ await db.beginTransaction();
49
+ for (const [scope, limit] of [[`email:${email}`, 5], [`ip:${ip}`, 20]]) {
50
+ const key = scopeHash(scope);
51
+ await db.execute('INSERT INTO email_send_limits (scope_hash,window_start,request_count) VALUES (?,?,1) ON DUPLICATE KEY UPDATE request_count=request_count+1', [key, windowStart]);
52
+ const [rows] = await db.execute('SELECT request_count FROM email_send_limits WHERE scope_hash=? AND window_start=? FOR UPDATE', [key, windowStart]);
53
+ if (rows[0].request_count > limit) throw error(429, 'Too many email requests');
54
+ }
55
+ const [existing] = await db.execute('SELECT sent_at FROM email_login_challenges WHERE email=? FOR UPDATE', [email]);
56
+ if (existing.length && Date.now() - new Date(`${existing[0].sent_at.replace(' ', 'T')}Z`).getTime() < 60000) {
57
+ throw error(429, 'Please wait before requesting another code');
58
+ }
59
+ await db.execute(`INSERT INTO email_login_challenges (email,code_hash,expires_at,attempts,sent_at)
60
+ VALUES (?, ?, UTC_TIMESTAMP(3) + INTERVAL 10 MINUTE, 0, UTC_TIMESTAMP(3))
61
+ ON DUPLICATE KEY UPDATE code_hash=VALUES(code_hash),expires_at=VALUES(expires_at),attempts=0,sent_at=VALUES(sent_at)`, [email, hash]);
62
+ await db.commit();
63
+ } catch (failure) { await db.rollback(); throw failure; }
64
+ finally { db.release(); }
65
+ try { await provider.sendLoginCode(email, code); }
66
+ catch (failure) {
67
+ await pool.execute('DELETE FROM email_login_challenges WHERE email=? AND code_hash=?', [email, hash]);
68
+ throw error(503, 'Email delivery unavailable');
69
+ }
70
+ return { sent: true, expiresInSeconds: 600 };
71
+ }
72
+
73
+ async function verifyCode({ email: rawEmail, code }) {
74
+ const email = normalizeEmail(rawEmail);
75
+ if (typeof code !== 'string' || !/^\d{6}$/.test(code)) throw error(400, 'Invalid code');
76
+ const db = await pool.getConnection();
77
+ try {
78
+ await db.beginTransaction();
79
+ const [challenges] = await db.execute('SELECT code_hash,attempts,expires_at FROM email_login_challenges WHERE email=? FOR UPDATE', [email]);
80
+ const challenge = challenges[0];
81
+ if (!challenge || new Date(`${challenge.expires_at.replace(' ', 'T')}Z`).getTime() <= Date.now()) throw error(401, 'Code expired or incorrect');
82
+ const supplied = Buffer.from(codeHash(email, code), 'hex');
83
+ if (!timingSafeEqual(supplied, Buffer.from(challenge.code_hash, 'hex'))) {
84
+ if (challenge.attempts >= 4) await db.execute('DELETE FROM email_login_challenges WHERE email=?', [email]);
85
+ else await db.execute('UPDATE email_login_challenges SET attempts=attempts+1 WHERE email=?', [email]);
86
+ await db.commit();
87
+ throw error(401, 'Code expired or incorrect');
88
+ }
89
+ await db.execute('DELETE FROM email_login_challenges WHERE email=?', [email]);
90
+ const [identities] = await db.execute(`SELECT u.id,u.status FROM user_login_identities i
91
+ JOIN users u ON u.id=i.user_id WHERE i.kind='email' AND i.normalized_value=?`, [email]);
92
+ let userId = identities[0]?.id;
93
+ if (identities[0]?.status === 'disabled') throw error(403, 'Account unavailable');
94
+ if (!userId) {
95
+ userId = randomUUID();
96
+ await db.execute('INSERT INTO users (id,status,created_at,updated_at) VALUES (?,\'active\',UTC_TIMESTAMP(3),UTC_TIMESTAMP(3))', [userId]);
97
+ await db.execute(`INSERT INTO user_login_identities (user_id,kind,normalized_value,verified_at,created_at)
98
+ VALUES (?,'email',?,UTC_TIMESTAMP(3),UTC_TIMESTAMP(3))`, [userId, email]);
99
+ }
100
+ const token = randomBytes(32).toString('base64url');
101
+ await db.execute(`INSERT INTO user_sessions (token_hash,user_id,created_at,expires_at)
102
+ VALUES (?,?,UTC_TIMESTAMP(3),UTC_TIMESTAMP(3) + INTERVAL 30 DAY)`, [tokenHash(token), userId]);
103
+ await db.commit();
104
+ return { token, user: { id: userId, email }, expiresInSeconds: 30 * 86400 };
105
+ } catch (failure) { await db.rollback(); throw failure; }
106
+ finally { db.release(); }
107
+ }
108
+
109
+ async function current(token) {
110
+ if (typeof token !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(token)) throw error(401, 'Unauthorized');
111
+ const [rows] = await pool.execute(`SELECT u.id,i.normalized_value AS email,s.expires_at FROM user_sessions s
112
+ JOIN users u ON u.id=s.user_id JOIN user_login_identities i ON i.user_id=u.id AND i.kind='email'
113
+ WHERE s.token_hash=? AND s.revoked_at IS NULL AND s.expires_at>UTC_TIMESTAMP(3) AND u.status='active'`, [tokenHash(token)]);
114
+ if (!rows.length) throw error(401, 'Unauthorized');
115
+ return { id: rows[0].id, email: rows[0].email, expiresAt: isoTime(rows[0].expires_at) };
116
+ }
117
+
118
+ async function logout(token) {
119
+ await current(token);
120
+ await pool.execute('UPDATE user_sessions SET revoked_at=UTC_TIMESTAMP(3) WHERE token_hash=? AND revoked_at IS NULL', [tokenHash(token)]);
121
+ return { ok: true };
122
+ }
123
+
124
+ return { captchaAppId: config.captchaAppId, requestCode, verifyCode, current, logout };
125
+ }
@@ -1,17 +1,14 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { execFile } from 'node:child_process';
4
- import { setTimeout as delay } from 'node:timers/promises';
5
- import { startBackground, processAlive } from '../background-process.mjs';
6
4
  import { promisify } from 'node:util';
7
5
  import { randomBytes, randomUUID } from 'node:crypto';
8
- import { fileURLToPath } from 'node:url';
9
6
  import { hostname } from 'node:os';
10
7
  import QRCode from 'qrcode';
11
8
  import { credentialStore } from '../platform/windows/credential-store.mjs';
12
9
  import { readRecord, writeRecord } from '../service-lifecycle.mjs';
13
10
  import { serviceHealth } from '../service-health.mjs';
14
- import { openControl, controlRequest } from '../service-control.mjs';
11
+ import { controlRequest } from '../service-control.mjs';
15
12
  import { connectBridgeRelay } from './connector.mjs';
16
13
  import { validProbeIceServers } from './p2p-probe.mjs';
17
14
  import { relayAddress, validSecret } from './wire.mjs';
@@ -19,8 +16,6 @@ import { saveLanAuthorizations } from './lan-authorizations.mjs';
19
16
  import { lanNetworks } from '../platform/windows/network-info.mjs';
20
17
 
21
18
  const exec = promisify(execFile);
22
- const script = fileURLToPath(import.meta.url);
23
- const recordPath = root => path.join(root, 'self-relay-session.json');
24
19
  const officialConfig = { url: 'wss://api.uulife.site/relay' };
25
20
 
26
21
  async function loadConfig(stateRoot) {
@@ -59,54 +54,16 @@ async function registrationKey(config) {
59
54
  return hostKey;
60
55
  }
61
56
  export async function selfRelay(command, stateRoot) {
62
- const previous = readRecord(recordPath(stateRoot));
63
- let current;
64
- if (previous) {
65
- try { current = await controlRequest(previous); }
66
- catch (error) { if (error.code !== 'ECONNREFUSED') throw error; }
67
- }
68
- if (command === 'relay-status' && current) return controlRequest(previous, 'POST', 'inspect');
69
- if (command === 'relay-status') return current || { running: false, displayText: '私有中继连接器未运行。' };
70
- if (command === 'relay-stop') {
71
- if (current) await controlRequest(previous, 'POST');
72
- const deadline = Date.now() + 10000;
73
- while (current && processAlive(previous.pid)) {
74
- if (Date.now() >= deadline) throw new Error('私有中继未在 10 秒内退出,未继续恢复服务。');
75
- await delay(100);
76
- }
77
- return { stopped: true, displayText: '私有中继连接器已停止;Bridge 和 Codex 保持运行。' };
78
- }
79
- if (current) {
80
- if (command === 'relay-revoke') return controlRequest(previous, 'POST', 'revoke');
81
- if (command === 'relay-pair' || (current.relayConnected && !current.paired && !current.qrPath)) return controlRequest(previous, 'POST', 'pair');
82
- if (command === 'relay-start' && current.relayConnected) return controlRequest(previous, 'POST', 'refresh-lan');
83
- return current;
84
- }
85
- if (command === 'relay-revoke') throw new Error('请先启动连接器,再撤销移动端授权。');
86
- fs.mkdirSync(stateRoot, { recursive: true });
87
- const instanceId = randomUUID();
88
- const log = path.join(stateRoot, 'self-relay.log');
89
- const readyPath = path.join(stateRoot, `relay-ready-${instanceId}.json`);
90
- const pid = await startBackground(process.execPath, [script, '--worker', stateRoot, instanceId,
91
- command === 'relay-pair' ? 'pair' : ''], process.env, [log, log]);
92
- try {
93
- const deadline = Date.now() + 25000;
94
- while (true) {
95
- const result = readRecord(readyPath);
96
- if (result) {
97
- if (result.pid !== pid || result.instanceId !== instanceId) throw new Error('私有中继启动实例不匹配。');
98
- if (result.error) throw new Error(result.error);
99
- return result.status;
100
- }
101
- if (!processAlive(pid)) throw new Error('私有中继启动失败,请查看 self-relay.log。');
102
- if (Date.now() >= deadline) throw new Error('私有中继启动超时。');
103
- await delay(100);
104
- }
105
- } catch (error) { if (processAlive(pid)) process.kill(pid); throw error; }
106
- finally { fs.rmSync(readyPath, { force: true }); }
107
- }
108
-
109
- async function worker(stateRoot, instanceId, refresh = false) {
57
+ const session = readRecord(path.join(stateRoot, 'bridge-session.json'));
58
+ if (!session) {
59
+ if (command === 'relay-status') return { running: false, displayText: 'Bridge 未运行。' };
60
+ if (command === 'relay-stop') return { stopped: true, displayText: '中继连接已停止。' };
61
+ throw new Error('Bridge 尚未运行,请先执行 relax start。');
62
+ }
63
+ return controlRequest(session, 'POST', command);
64
+ }
65
+
66
+ async function createRelayRuntime(stateRoot) {
110
67
  const config = await loadConfig(stateRoot);
111
68
  const session = () => readRecord(path.join(stateRoot, 'bridge-session.json'));
112
69
  let health = await serviceHealth(session());
@@ -132,8 +89,7 @@ async function worker(stateRoot, instanceId, refresh = false) {
132
89
  try { health = await serviceHealth(session()) || { transportMode: health.transportMode, status: 'offline' }; bridgeError = ''; }
133
90
  catch (error) { bridgeError = error.message; health = { transportMode: health.transportMode, status: 'offline' }; }
134
91
  }
135
- const secret = randomBytes(32).toString('base64url');
136
- let connector, control, qrPath, expiresAt = 0, paired = false, clientCount = 0, attachedCount = 0;
92
+ let connector, qrPath, expiresAt = 0, paired = false, clientCount = 0, attachedCount = 0;
137
93
  let closing = false, online = false, connecting = false, retryTimer, attempts = 0, lastError = '';
138
94
  const audit = (event, details = {}) => console.log(JSON.stringify({ time: new Date().toISOString(), event, ...details }));
139
95
  async function currentLanCandidates() {
@@ -161,8 +117,6 @@ async function worker(stateRoot, instanceId, refresh = false) {
161
117
  if (closing) return;
162
118
  closing = true; clearTimeout(retryTimer);
163
119
  connector?.events.removeAllListeners(); connector?.close();
164
- if (readRecord(recordPath(stateRoot))?.instanceId === instanceId) fs.rmSync(recordPath(stateRoot), { force: true });
165
- await control?.close();
166
120
  }
167
121
  async function saveInvitation(message) {
168
122
  if (!message.code) return;
@@ -245,25 +199,39 @@ async function worker(stateRoot, instanceId, refresh = false) {
245
199
  audit('lan-candidates-updated', { count: candidates.length });
246
200
  return { ...status(), lanCandidateCount: candidates.length };
247
201
  }
248
- try {
249
- const freshStatus = async () => {
250
- if (online) {
251
- const remote = await connector.command('status'); paired = remote.paired;
252
- await refreshHealth();
253
- }
254
- return status();
255
- };
256
- control = await openControl({ instanceId, secret, shutdown, status, actions: { pair, revoke, inspect: freshStatus, 'refresh-lan': refreshLan } });
257
- writeRecord(recordPath(stateRoot), { pid: process.pid, instanceId, controlSecret: secret, controlPort: control.port });
258
- await connect();
259
- const result = refresh && online ? await pair() : status();
260
- writeRecord(path.join(stateRoot, `relay-ready-${instanceId}.json`), { instanceId, pid: process.pid, status: { ...result, pid: process.pid } });
261
- } catch (error) { await shutdown(); throw error; }
262
- }
263
-
264
- if (process.argv[1] && path.resolve(process.argv[1]) === script && process.argv[2] === '--worker') {
265
- worker(process.argv[3], process.argv[4], process.argv[5] === 'pair').catch(error => {
266
- writeRecord(path.join(process.argv[3], `relay-ready-${process.argv[4]}.json`), { instanceId: process.argv[4], pid: process.pid, error: error.message });
267
- console.error(error.message); process.exitCode = 1;
268
- });
269
- }
202
+ const inspect = async () => {
203
+ if (online) {
204
+ const remote = await connector.command('status'); paired = remote.paired;
205
+ await refreshHealth();
206
+ }
207
+ return status();
208
+ };
209
+ try { await connect(); return { status, inspect, pair, revoke, refreshLan, shutdown }; }
210
+ catch (error) { await shutdown(); throw error; }
211
+ }
212
+
213
+ export function createRelayManager(stateRoot) {
214
+ let current;
215
+ let operation = Promise.resolve();
216
+ const run = command => {
217
+ const next = operation.then(async () => {
218
+ if (command === 'relay-status') return current ? current.inspect() : { running: false, displayText: '中继连接未运行。' };
219
+ if (command === 'relay-stop') {
220
+ await current?.shutdown(); current = undefined;
221
+ return { stopped: true, displayText: '中继连接已停止;Bridge 和 Codex 保持运行。' };
222
+ }
223
+ if (!current) {
224
+ if (command === 'relay-revoke') throw new Error('请先启动中继连接,再撤销移动端授权。');
225
+ current = await createRelayRuntime(stateRoot);
226
+ return command === 'relay-pair' && current.status().relayConnected ? current.pair() : current.status();
227
+ }
228
+ if (command === 'relay-revoke') return current.revoke();
229
+ if (command === 'relay-pair' || (current.status().relayConnected && !current.status().paired && !current.status().qrPath)) return current.pair();
230
+ if (command === 'relay-start' && current.status().relayConnected) return current.refreshLan();
231
+ return current.status();
232
+ });
233
+ operation = next.catch(() => {});
234
+ return next;
235
+ };
236
+ return { run, close: () => run('relay-stop'), status: () => current?.status() || { running: false } };
237
+ }
@@ -0,0 +1,104 @@
1
+ import fs from 'node:fs';
2
+ import { createHash } from 'node:crypto';
3
+ import { fileURLToPath } from 'node:url';
4
+ import mysql from 'mysql2/promise';
5
+ import { deviceStore } from './device-store.mjs';
6
+ import { relayAdminState } from './admin-state.mjs';
7
+
8
+ const sqlTime = value => value.replace('T', ' ').replace(/Z$/, '');
9
+ const isoTime = value => value.replace(' ', 'T') + 'Z';
10
+
11
+ export async function openRelayMysql(configFile, initialMaxRelaySessions) {
12
+ const config = JSON.parse(fs.readFileSync(configFile, 'utf8'));
13
+ if (!config.database || !config.user || !config.password || !(config.socketPath || config.host)) {
14
+ throw new Error('Incomplete relay MySQL configuration');
15
+ }
16
+ const pool = mysql.createPool({ ...config, waitForConnections: true, connectionLimit: 4,
17
+ namedPlaceholders: false, dateStrings: true, timezone: 'Z', multipleStatements: false });
18
+ try {
19
+ const [versions] = await pool.query('SELECT version, sha256 FROM schema_migrations');
20
+ const knownVersions = ['001_relay_core', '002_email_accounts'];
21
+ if (!versions.some(row => row.version === knownVersions[0]) || versions.length > knownVersions.length) throw new Error('Unexpected relay database schema');
22
+ for (const row of versions) {
23
+ if (!knownVersions.includes(row.version)) throw new Error('Unexpected relay database schema');
24
+ const source = fs.readFileSync(fileURLToPath(new URL(`../../database/${row.version}.sql`, import.meta.url)));
25
+ if (row.sha256 !== createHash('sha256').update(source).digest('hex')) throw new Error(`Unexpected relay database schema: ${row.version}`);
26
+ }
27
+ const [computers] = await pool.query('SELECT id, host_hash, status, revision, created_at, updated_at FROM computers');
28
+ const [clients] = await pool.query('SELECT computer_id, client_id, credential_hash, created_at, updated_at FROM mobile_authorizations ORDER BY created_at');
29
+ const [official] = await pool.query('SELECT computer_id, environment_id, name, hostname, updated_at FROM official_computers');
30
+ const records = new Map(computers.map(row => [row.id, { id: row.id, hostHash: row.host_hash, status: row.status,
31
+ revision: Number(row.revision), createdAt: isoTime(row.created_at), updatedAt: isoTime(row.updated_at), clients: [] }]));
32
+ for (const row of clients) {
33
+ const record = records.get(row.computer_id);
34
+ if (!record) throw new Error('Orphan mobile authorization');
35
+ record.clients.push({ id: row.client_id, hash: row.credential_hash,
36
+ createdAt: isoTime(row.created_at), updatedAt: isoTime(row.updated_at) });
37
+ }
38
+ for (const row of official) {
39
+ const record = records.get(row.computer_id);
40
+ if (!record) throw new Error('Orphan official computer');
41
+ record.officialComputer = { environmentId: row.environment_id, name: row.name,
42
+ hostname: row.hostname, updatedAt: isoTime(row.updated_at) };
43
+ }
44
+ const validator = deviceStore();
45
+ for (const record of records.values()) validator.save(record);
46
+
47
+ const [settings] = await pool.query('SELECT max_relay_sessions FROM relay_settings WHERE id = 1');
48
+ if (settings.length !== 1) throw new Error('Missing relay settings');
49
+ const [rows] = await pool.query('SELECT id, title, message, status, created_at, updated_at, published_at FROM announcements ORDER BY created_at DESC');
50
+ const announcements = rows.map(row => ({ id: row.id, title: row.title, message: row.message, status: row.status,
51
+ createdAt: isoTime(row.created_at), updatedAt: isoTime(row.updated_at),
52
+ publishedAt: row.published_at ? isoTime(row.published_at) : null }));
53
+ const active = announcements.find(item => item.status === 'active');
54
+ const initial = { version: 3, maxRelaySessions: settings[0].max_relay_sessions, announcements,
55
+ announcement: active ? { id: active.id, title: active.title, message: active.message, publishedAt: active.publishedAt } : null };
56
+ const admin = relayAdminState(undefined, initialMaxRelaySessions, { initial, persist: async next => {
57
+ const connection = await pool.getConnection();
58
+ try {
59
+ await connection.beginTransaction();
60
+ await connection.execute('UPDATE relay_settings SET max_relay_sessions = ?, updated_at = UTC_TIMESTAMP(3) WHERE id = 1', [next.maxRelaySessions]);
61
+ await connection.query('DELETE FROM announcements');
62
+ for (const item of next.announcements) await connection.execute(
63
+ 'INSERT INTO announcements (id,title,message,status,created_at,updated_at,published_at) VALUES (?,?,?,?,?,?,?)',
64
+ [item.id, item.title, item.message, item.status, sqlTime(item.createdAt), sqlTime(item.updatedAt),
65
+ item.publishedAt ? sqlTime(item.publishedAt) : null]);
66
+ await connection.commit();
67
+ } catch (error) { await connection.rollback(); throw error; }
68
+ finally { connection.release(); }
69
+ } });
70
+ return {
71
+ pool,
72
+ schemaVersions: versions.map(row => row.version),
73
+ devices: {
74
+ entries: () => [...records.values()],
75
+ async save(record) {
76
+ validator.save(record);
77
+ const connection = await pool.getConnection();
78
+ try {
79
+ await connection.beginTransaction();
80
+ await connection.execute(`INSERT INTO computers (id,host_hash,status,revision,created_at,updated_at)
81
+ VALUES (?,?,?,?,?,?) ON DUPLICATE KEY UPDATE host_hash=VALUES(host_hash),status=VALUES(status),
82
+ revision=VALUES(revision),updated_at=VALUES(updated_at)`,
83
+ [record.id, record.hostHash, record.status, record.revision, sqlTime(record.createdAt), sqlTime(record.updatedAt)]);
84
+ await connection.execute('DELETE FROM mobile_authorizations WHERE computer_id = ?', [record.id]);
85
+ for (const client of record.clients) await connection.execute(
86
+ 'INSERT INTO mobile_authorizations (computer_id,client_id,credential_hash,created_at,updated_at) VALUES (?,?,?,?,?)',
87
+ [record.id, client.id, client.hash, sqlTime(client.createdAt), sqlTime(client.updatedAt)]);
88
+ if (record.officialComputer) {
89
+ const item = record.officialComputer;
90
+ await connection.execute(`INSERT INTO official_computers (computer_id,environment_id,name,hostname,updated_at)
91
+ VALUES (?,?,?,?,?) ON DUPLICATE KEY UPDATE environment_id=VALUES(environment_id),name=VALUES(name),
92
+ hostname=VALUES(hostname),updated_at=VALUES(updated_at)`,
93
+ [record.id, item.environmentId, item.name, item.hostname, sqlTime(item.updatedAt)]);
94
+ }
95
+ await connection.commit(); records.set(record.id, record);
96
+ } catch (error) { await connection.rollback(); throw error; }
97
+ finally { connection.release(); }
98
+ },
99
+ },
100
+ admin,
101
+ close: () => pool.end(),
102
+ };
103
+ } catch (error) { await pool.end(); throw error; }
104
+ }