@walkhi/code-relax 0.1.0-beta.65 → 0.1.0-beta.68

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.
Files changed (121) hide show
  1. package/dist/bin/codex-remote.mjs +4 -2
  2. package/dist/bin/manage-relay-admin.mjs +20 -20
  3. package/dist/bin/migrate-relay-db.mjs +109 -109
  4. package/dist/bin/publish-harmony-release.mjs +46 -46
  5. package/dist/bin/self-relay-admin.mjs +8 -8
  6. package/dist/bin/self-relay-demo.mjs +33 -33
  7. package/dist/bin/self-relay-server.mjs +66 -66
  8. package/dist/bin/set-relay-admin-password.mjs +11 -11
  9. package/dist/build-platform.json +1 -1
  10. package/dist/get-lan-networks.ps1 +40 -40
  11. package/dist/shared/app-server-events.cjs +511 -511
  12. package/dist/shared/p2p-data-channel.cjs +72 -72
  13. package/dist/shared/p2p-diagnostics.cjs +28 -28
  14. package/dist/shared/p2p-ice-policy.cjs +22 -22
  15. package/dist/shared/plan-pricing.cjs +32 -32
  16. package/dist/shared/question-attention.cjs +69 -69
  17. package/dist/shared/release-version.cjs +31 -31
  18. package/dist/src/archived-threads.mjs +22 -22
  19. package/dist/src/background-process.mjs +18 -18
  20. package/dist/src/code-browser.mjs +100 -100
  21. package/dist/src/desktop-launcher.mjs +62 -62
  22. package/dist/src/dots-task-files.mjs +52 -52
  23. package/dist/src/execution-health.mjs +26 -26
  24. package/dist/src/message-images.mjs +9 -9
  25. package/dist/src/plan-pricing.mjs +47 -47
  26. package/dist/src/platform/codex-runtime.mjs +3 -3
  27. package/dist/src/platform/credential-store.mjs +19 -19
  28. package/dist/src/platform/desktop-host.mjs +3 -3
  29. package/dist/src/platform/desktop-permission-mode.mjs +17 -17
  30. package/dist/src/platform/desktop-project-registration.mjs +3 -3
  31. package/dist/src/platform/file-opener.mjs +9 -9
  32. package/dist/src/platform/linux/README.md +3 -3
  33. package/dist/src/platform/linux/codex-runtime.mjs +56 -56
  34. package/dist/src/platform/linux/desktop-installation.mjs +36 -36
  35. package/dist/src/platform/linux/desktop-project-registration.mjs +11 -11
  36. package/dist/src/platform/linux/network-info.mjs +11 -11
  37. package/dist/src/platform/linux/project-locations.mjs +17 -17
  38. package/dist/src/platform/linux/resident-startup.mjs +52 -52
  39. package/dist/src/platform/linux/retained-runtime.mjs +76 -76
  40. package/dist/src/platform/linux/service-host.mjs +18 -18
  41. package/dist/src/platform/macos/README.md +3 -3
  42. package/dist/src/platform/macos/codex-runtime.mjs +0 -4
  43. package/dist/src/platform/macos/desktop-project-registration.mjs +11 -11
  44. package/dist/src/platform/macos/network-info.mjs +8 -8
  45. package/dist/src/platform/macos/project-locations.mjs +8 -8
  46. package/dist/src/platform/macos/resident-startup.mjs +84 -84
  47. package/dist/src/platform/macos/retained-runtime.mjs +127 -127
  48. package/dist/src/platform/macos/service-host.mjs +18 -18
  49. package/dist/src/platform/macos/system.mjs +29 -29
  50. package/dist/src/platform/network-info.mjs +3 -3
  51. package/dist/src/platform/path-identity.mjs +5 -5
  52. package/dist/src/platform/posix/network-candidates.mjs +23 -23
  53. package/dist/src/platform/posix/runtime-paths.mjs +25 -25
  54. package/dist/src/platform/power-inhibitor.mjs +22 -22
  55. package/dist/src/platform/project-locations.mjs +3 -3
  56. package/dist/src/platform/runtime-paths.mjs +8 -8
  57. package/dist/src/platform/select.mjs +5 -5
  58. package/dist/src/platform/service-host.mjs +3 -3
  59. package/dist/src/platform/user-environment.mjs +3 -3
  60. package/dist/src/platform/windows/IsolatedProcess.cs +79 -79
  61. package/dist/src/platform/windows/background-process.mjs +30 -30
  62. package/dist/src/platform/windows/desktop-helper.mjs +179 -179
  63. package/dist/src/platform/windows/desktop-runtime.ps1 +10 -10
  64. package/dist/src/platform/windows/desktop-shortcuts.ps1 +7 -7
  65. package/dist/src/platform/windows/keep-awake.ps1 +20 -20
  66. package/dist/src/platform/windows/network-info.mjs +34 -34
  67. package/dist/src/platform/windows/resident-start.js +20 -20
  68. package/dist/src/platform/windows/resident-start.ps1 +7 -7
  69. package/dist/src/platform/windows/resident-startup.ps1 +83 -83
  70. package/dist/src/platform/windows/resolve-state-root.ps1 +14 -14
  71. package/dist/src/platform/windows/runtime-paths.mjs +20 -20
  72. package/dist/src/platform/windows/rustdesk-video-probe.mjs +167 -167
  73. package/dist/src/platform/windows/start-hidden-console.ps1 +58 -58
  74. package/dist/src/platform/windows/stop-desktop.ps1 +22 -22
  75. package/dist/src/platform/windows/user-environment.ps1 +41 -41
  76. package/dist/src/project-create.mjs +121 -121
  77. package/dist/src/project-management.mjs +1 -1
  78. package/dist/src/project-remove.mjs +55 -55
  79. package/dist/src/project-update.mjs +59 -59
  80. package/dist/src/relay-config-import.mjs +45 -45
  81. package/dist/src/self-relay/account-connections.mjs +52 -52
  82. package/dist/src/self-relay/admin-auth.mjs +59 -59
  83. package/dist/src/self-relay/admin-cloud.mjs +101 -101
  84. package/dist/src/self-relay/admin-login-page.mjs +7 -7
  85. package/dist/src/self-relay/admin-server.mjs +105 -105
  86. package/dist/src/self-relay/admin-state.mjs +127 -127
  87. package/dist/src/self-relay/admin-workstations-page.mjs +95 -95
  88. package/dist/src/self-relay/admin-workstations.mjs +143 -143
  89. package/dist/src/self-relay/android-releases.mjs +61 -61
  90. package/dist/src/self-relay/client.mjs +61 -61
  91. package/dist/src/self-relay/connection-reporting.mjs +38 -38
  92. package/dist/src/self-relay/debug-allowlist.mjs +33 -33
  93. package/dist/src/self-relay/demo.mjs +6 -6
  94. package/dist/src/self-relay/device-info.mjs +27 -27
  95. package/dist/src/self-relay/device-store.mjs +92 -92
  96. package/dist/src/self-relay/lan-authorizations.mjs +71 -71
  97. package/dist/src/self-relay/lifecycle.mjs +278 -278
  98. package/dist/src/self-relay/long-pairing.mjs +40 -40
  99. package/dist/src/self-relay/mysql-state.mjs +118 -118
  100. package/dist/src/self-relay/phone-connections.mjs +55 -55
  101. package/dist/src/self-relay/server.mjs +1066 -1066
  102. package/dist/src/self-relay/usage-daily.mjs +55 -55
  103. package/dist/src/self-relay/windows-route-helper.mjs +75 -75
  104. package/dist/src/self-relay/wire.mjs +36 -36
  105. package/dist/src/server.mjs +1947 -1947
  106. package/dist/src/service-control.mjs +82 -82
  107. package/dist/src/service-health.mjs +33 -33
  108. package/dist/src/service-power.mjs +58 -58
  109. package/dist/src/service-restart.mjs +81 -81
  110. package/dist/src/service-update-worker.mjs +42 -42
  111. package/dist/src/service-update.mjs +80 -80
  112. package/dist/src/shared-recovery.mjs +152 -152
  113. package/dist/src/skill-install.mjs +51 -51
  114. package/dist/src/thread-title-generation.mjs +150 -150
  115. package/dist/src/turn-model-history.mjs +47 -47
  116. package/dist/tools/rtc-route-helper.ps1 +99 -99
  117. package/dist/tools/start-rtc-route-helper.ps1 +15 -15
  118. package/dist/web/vendor/markdown-it.LICENSE.txt +22 -22
  119. package/dist/web/vendor/markdown-it.umd.min.js +13 -13
  120. package/package.json +4 -4
  121. package/tools/postinstall.mjs +1 -1
@@ -1,105 +1,105 @@
1
- import http from 'node:http';
2
- import fs from 'node:fs';
3
- import { adminPage } from './admin-page.mjs';
4
- import { startAdminCloud } from './admin-cloud.mjs';
5
- import { MAX_ADMIN_REQUEST_BYTES } from './admin-state.mjs';
6
- import { createAdminAuth } from './admin-auth.mjs';
7
- import { adminLoginPage } from './admin-login-page.mjs';
8
- import endpoints from '../../shared/service-endpoints.cjs';
9
-
10
- function reply(response, status, body, type = 'application/json; charset=utf-8') {
11
- const bytes = typeof body === 'string' ? body : JSON.stringify(body);
12
- response.writeHead(status, { 'Content-Type': type, 'Content-Length': Buffer.byteLength(bytes),
13
- 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff',
14
- 'Referrer-Policy': 'no-referrer', 'X-Robots-Tag': 'noindex, nofollow', 'X-Frame-Options': 'DENY' });
15
- response.end(bytes);
16
- }
17
-
18
- async function readBody(request) {
19
- const chunks = [];
20
- let size = 0;
21
- for await (const chunk of request) {
22
- size += chunk.length;
23
- if (size > MAX_ADMIN_REQUEST_BYTES) throw Object.assign(new Error('Admin request too large'), { status: 413 });
24
- chunks.push(chunk);
25
- }
26
- return Buffer.concat(chunks);
27
- }
28
-
29
- export async function startAdminServer({ port = 45851, relayUrl = 'http://127.0.0.1:45850', accessFile = '/etc/codex-relay/admin-access.json',
30
- passwordFile = '/etc/codex-relay/admin-password.json', publicOrigin = endpoints.adminOrigin } = {}) {
31
- const { key } = JSON.parse(fs.readFileSync(accessFile, 'utf8'));
32
- if (!/^[A-Za-z0-9_-]{43}$/.test(key)) throw new Error('Invalid admin access key');
33
- const auth = createAdminAuth(passwordFile);
34
- const cloud = startAdminCloud();
35
- const relayOrigin = new URL(relayUrl);
36
- if (relayOrigin.hostname !== '127.0.0.1' || relayOrigin.protocol !== 'http:' || relayOrigin.pathname !== '/') {
37
- cloud.close(); throw new Error('Admin relay endpoint must be loopback HTTP');
38
- }
39
- const server = http.createServer(async (request, response) => {
40
- const target = new URL(request.url || '/', 'http://localhost');
41
- if (request.method === 'GET' && target.pathname === '/admin/health') { reply(response, 200, { ok: true }); return; }
42
- if (!['GET', 'HEAD'].includes(request.method) && (request.headers.origin !== publicOrigin
43
- || request.headers['sec-fetch-site'] === 'cross-site' || request.headers['x-code-relax-admin'] !== '1')) {
44
- reply(response, 403, { error: 'Cross-site request rejected' }); return;
45
- }
46
- if (request.method === 'GET' && target.pathname === '/') {
47
- reply(response, 200, auth.authenticated(request) ? adminPage() : adminLoginPage(), 'text/html; charset=utf-8'); return;
48
- }
49
- if (request.method === 'GET' && ['/admin', '/admin/'].includes(target.pathname)) {
50
- response.writeHead(303, { Location: '/', 'Cache-Control': 'no-store' }); response.end(); return;
51
- }
52
- if (request.method === 'POST' && target.pathname === '/admin/login') {
53
- try {
54
- if (request.headers['content-type']?.split(';')[0] !== 'application/json') {
55
- reply(response, 415, { error: 'Expected application/json' }); return;
56
- }
57
- const { password } = JSON.parse((await readBody(request)).toString('utf8'));
58
- const result = await auth.login(password, request, response);
59
- reply(response, result.status, result.error ? { error: result.error } : { ok: true });
60
- } catch (error) {
61
- const status = error.status || (error instanceof SyntaxError || error instanceof TypeError ? 400 : 500);
62
- if (status === 500) console.error('Admin login failed:', error.message);
63
- reply(response, status, { error: status === 500 ? '登录服务暂不可用' : '登录请求无效' });
64
- }
65
- return;
66
- }
67
- if (request.method === 'POST' && target.pathname === '/admin/logout') {
68
- auth.logout(request, response); reply(response, 200, { ok: true }); return;
69
- }
70
- if (!target.pathname.startsWith('/admin/api/')) { reply(response, 404, { error: 'Not found' }); return; }
71
- if (!auth.authenticated(request)) { reply(response, 401, { error: '登录已过期,请重新登录' }); return; }
72
- if (request.method === 'GET' && (['/admin/api/status', '/admin/api/usage', '/admin/api/usage-diagnostics',
73
- '/admin/api/workstations'].includes(target.pathname) || /^\/admin\/api\/workstations\/[\w-]{16,128}$/.test(target.pathname))) {
74
- try {
75
- const upstream = await fetch(new URL(target.pathname + target.search, relayOrigin), {
76
- signal: AbortSignal.timeout(3000), headers: { 'X-Code-Relax-Admin-Key': key } });
77
- const state = await upstream.json();
78
- if (!upstream.ok) { reply(response, upstream.status, state); return; }
79
- reply(response, 200, target.pathname === '/admin/api/status'
80
- ? { ...state, relayAvailable: true, cloud: cloud.read() } : state);
81
- } catch {
82
- reply(response, 503, { error: '中继服务暂不可用' });
83
- }
84
- return;
85
- }
86
- if (!target.pathname.startsWith('/admin/api/') || !['PUT', 'DELETE', 'POST'].includes(request.method || '')) {
87
- reply(response, 404, { error: 'Not found' }); return;
88
- }
89
- try {
90
- const body = await readBody(request);
91
- const upstream = await fetch(new URL(target.pathname + target.search, relayOrigin), {
92
- method: request.method, signal: AbortSignal.timeout(3000),
93
- headers: { 'Content-Type': request.headers['content-type'] || '',
94
- 'X-Code-Relax-Admin': request.headers['x-code-relax-admin'] || '',
95
- 'X-Code-Relax-Admin-Key': key },
96
- body,
97
- });
98
- reply(response, upstream.status, await upstream.text());
99
- } catch (error) { reply(response, error.status || 503, { error: error.status === 413 ? error.message : '中继服务暂不可用' }); }
100
- });
101
- try {
102
- await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, '127.0.0.1', resolve); });
103
- } catch (error) { cloud.close(); throw error; }
104
- return { port: server.address().port, async close() { cloud.close(); await new Promise(resolve => server.close(resolve)); } };
105
- }
1
+ import http from 'node:http';
2
+ import fs from 'node:fs';
3
+ import { adminPage } from './admin-page.mjs';
4
+ import { startAdminCloud } from './admin-cloud.mjs';
5
+ import { MAX_ADMIN_REQUEST_BYTES } from './admin-state.mjs';
6
+ import { createAdminAuth } from './admin-auth.mjs';
7
+ import { adminLoginPage } from './admin-login-page.mjs';
8
+ import endpoints from '../../shared/service-endpoints.cjs';
9
+
10
+ function reply(response, status, body, type = 'application/json; charset=utf-8') {
11
+ const bytes = typeof body === 'string' ? body : JSON.stringify(body);
12
+ response.writeHead(status, { 'Content-Type': type, 'Content-Length': Buffer.byteLength(bytes),
13
+ 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff',
14
+ 'Referrer-Policy': 'no-referrer', 'X-Robots-Tag': 'noindex, nofollow', 'X-Frame-Options': 'DENY' });
15
+ response.end(bytes);
16
+ }
17
+
18
+ async function readBody(request) {
19
+ const chunks = [];
20
+ let size = 0;
21
+ for await (const chunk of request) {
22
+ size += chunk.length;
23
+ if (size > MAX_ADMIN_REQUEST_BYTES) throw Object.assign(new Error('Admin request too large'), { status: 413 });
24
+ chunks.push(chunk);
25
+ }
26
+ return Buffer.concat(chunks);
27
+ }
28
+
29
+ export async function startAdminServer({ port = 45851, relayUrl = 'http://127.0.0.1:45850', accessFile = '/etc/codex-relay/admin-access.json',
30
+ passwordFile = '/etc/codex-relay/admin-password.json', publicOrigin = endpoints.adminOrigin } = {}) {
31
+ const { key } = JSON.parse(fs.readFileSync(accessFile, 'utf8'));
32
+ if (!/^[A-Za-z0-9_-]{43}$/.test(key)) throw new Error('Invalid admin access key');
33
+ const auth = createAdminAuth(passwordFile);
34
+ const cloud = startAdminCloud();
35
+ const relayOrigin = new URL(relayUrl);
36
+ if (relayOrigin.hostname !== '127.0.0.1' || relayOrigin.protocol !== 'http:' || relayOrigin.pathname !== '/') {
37
+ cloud.close(); throw new Error('Admin relay endpoint must be loopback HTTP');
38
+ }
39
+ const server = http.createServer(async (request, response) => {
40
+ const target = new URL(request.url || '/', 'http://localhost');
41
+ if (request.method === 'GET' && target.pathname === '/admin/health') { reply(response, 200, { ok: true }); return; }
42
+ if (!['GET', 'HEAD'].includes(request.method) && (request.headers.origin !== publicOrigin
43
+ || request.headers['sec-fetch-site'] === 'cross-site' || request.headers['x-code-relax-admin'] !== '1')) {
44
+ reply(response, 403, { error: 'Cross-site request rejected' }); return;
45
+ }
46
+ if (request.method === 'GET' && target.pathname === '/') {
47
+ reply(response, 200, auth.authenticated(request) ? adminPage() : adminLoginPage(), 'text/html; charset=utf-8'); return;
48
+ }
49
+ if (request.method === 'GET' && ['/admin', '/admin/'].includes(target.pathname)) {
50
+ response.writeHead(303, { Location: '/', 'Cache-Control': 'no-store' }); response.end(); return;
51
+ }
52
+ if (request.method === 'POST' && target.pathname === '/admin/login') {
53
+ try {
54
+ if (request.headers['content-type']?.split(';')[0] !== 'application/json') {
55
+ reply(response, 415, { error: 'Expected application/json' }); return;
56
+ }
57
+ const { password } = JSON.parse((await readBody(request)).toString('utf8'));
58
+ const result = await auth.login(password, request, response);
59
+ reply(response, result.status, result.error ? { error: result.error } : { ok: true });
60
+ } catch (error) {
61
+ const status = error.status || (error instanceof SyntaxError || error instanceof TypeError ? 400 : 500);
62
+ if (status === 500) console.error('Admin login failed:', error.message);
63
+ reply(response, status, { error: status === 500 ? '登录服务暂不可用' : '登录请求无效' });
64
+ }
65
+ return;
66
+ }
67
+ if (request.method === 'POST' && target.pathname === '/admin/logout') {
68
+ auth.logout(request, response); reply(response, 200, { ok: true }); return;
69
+ }
70
+ if (!target.pathname.startsWith('/admin/api/')) { reply(response, 404, { error: 'Not found' }); return; }
71
+ if (!auth.authenticated(request)) { reply(response, 401, { error: '登录已过期,请重新登录' }); return; }
72
+ if (request.method === 'GET' && (['/admin/api/status', '/admin/api/usage', '/admin/api/usage-diagnostics',
73
+ '/admin/api/workstations'].includes(target.pathname) || /^\/admin\/api\/workstations\/[\w-]{16,128}$/.test(target.pathname))) {
74
+ try {
75
+ const upstream = await fetch(new URL(target.pathname + target.search, relayOrigin), {
76
+ signal: AbortSignal.timeout(3000), headers: { 'X-Code-Relax-Admin-Key': key } });
77
+ const state = await upstream.json();
78
+ if (!upstream.ok) { reply(response, upstream.status, state); return; }
79
+ reply(response, 200, target.pathname === '/admin/api/status'
80
+ ? { ...state, relayAvailable: true, cloud: cloud.read() } : state);
81
+ } catch {
82
+ reply(response, 503, { error: '中继服务暂不可用' });
83
+ }
84
+ return;
85
+ }
86
+ if (!target.pathname.startsWith('/admin/api/') || !['PUT', 'DELETE', 'POST'].includes(request.method || '')) {
87
+ reply(response, 404, { error: 'Not found' }); return;
88
+ }
89
+ try {
90
+ const body = await readBody(request);
91
+ const upstream = await fetch(new URL(target.pathname + target.search, relayOrigin), {
92
+ method: request.method, signal: AbortSignal.timeout(3000),
93
+ headers: { 'Content-Type': request.headers['content-type'] || '',
94
+ 'X-Code-Relax-Admin': request.headers['x-code-relax-admin'] || '',
95
+ 'X-Code-Relax-Admin-Key': key },
96
+ body,
97
+ });
98
+ reply(response, upstream.status, await upstream.text());
99
+ } catch (error) { reply(response, error.status || 503, { error: error.status === 413 ? error.message : '中继服务暂不可用' }); }
100
+ });
101
+ try {
102
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, '127.0.0.1', resolve); });
103
+ } catch (error) { cloud.close(); throw error; }
104
+ return { port: server.address().port, async close() { cloud.close(); await new Promise(resolve => server.close(resolve)); } };
105
+ }
@@ -1,127 +1,127 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import { randomUUID } from 'node:crypto';
4
-
5
- // 80 title + 2000 body UTF-16 units fit even when every unit uses a six-byte JSON escape.
6
- export const MAX_ADMIN_REQUEST_BYTES = 16 * 1024;
7
-
8
- export function relayAdminState(file, initialMaxRelaySessions, { initial, persist } = {}) {
9
- let value = initial ? validate(initial) : { version: 3, maxRelaySessions: initialMaxRelaySessions, announcement: null, announcements: [] };
10
- if (file && fs.existsSync(file)) value = validate(JSON.parse(fs.readFileSync(file, 'utf8')));
11
-
12
- async function save(next) {
13
- const validated = validate(next);
14
- if (persist) await persist(validated);
15
- if (!file) { value = validated; return value; }
16
- fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
17
- const temporary = `${file}.${randomUUID()}.tmp`;
18
- let descriptor;
19
- try {
20
- descriptor = fs.openSync(temporary, 'wx', 0o600);
21
- fs.writeFileSync(descriptor, JSON.stringify(validated));
22
- fs.fsyncSync(descriptor); fs.closeSync(descriptor); descriptor = undefined;
23
- fs.renameSync(temporary, file);
24
- if (process.platform !== 'win32') {
25
- const directory = fs.openSync(path.dirname(file), 'r');
26
- try { fs.fsyncSync(directory); } finally { fs.closeSync(directory); }
27
- }
28
- } finally {
29
- if (descriptor !== undefined) fs.closeSync(descriptor);
30
- fs.rmSync(temporary, { force: true });
31
- }
32
- value = validated;
33
- return value;
34
- }
35
-
36
- return {
37
- read: () => structuredClone(value),
38
- setMaxRelaySessions(maxRelaySessions) { return save({ ...value, maxRelaySessions }); },
39
- createAnnouncement(title, message, publish = false) {
40
- const now = new Date().toISOString();
41
- const record = { id: randomUUID(), title: clean(title, 80), message: clean(message, 2000),
42
- createdAt: now, updatedAt: now, publishedAt: publish ? now : null, status: publish ? 'active' : 'draft' };
43
- const announcements = value.announcements.map(item => publish && item.status === 'active'
44
- ? { ...item, status: 'withdrawn', updatedAt: now } : item);
45
- return save({ ...value, announcement: publish ? publicAnnouncement(record) : value.announcement,
46
- announcements: [record, ...announcements] });
47
- },
48
- updateDraft(id, title, message) {
49
- const existing = value.announcements.find(item => item.id === id);
50
- if (!existing || existing.status !== 'draft') throw new Error('Only drafts can be edited');
51
- return save({ ...value, announcements: value.announcements.map(item => item.id === id
52
- ? { ...item, title: clean(title, 80), message: clean(message, 2000), updatedAt: new Date().toISOString() } : item) });
53
- },
54
- publishDraft(id, title, message) {
55
- const existing = value.announcements.find(item => item.id === id);
56
- if (!existing || existing.status !== 'draft') throw new Error('Only drafts can be published');
57
- const now = new Date().toISOString();
58
- const published = { ...existing, title: title === undefined ? existing.title : clean(title, 80),
59
- message: message === undefined ? existing.message : clean(message, 2000),
60
- publishedAt: now, updatedAt: now, status: 'active' };
61
- return save({ ...value, announcement: publicAnnouncement(published),
62
- announcements: value.announcements.map(item => item.id === id ? published : item.status === 'active'
63
- ? { ...item, status: 'withdrawn', updatedAt: now } : item) });
64
- },
65
- withdraw(id) {
66
- if (!value.announcement || value.announcement.id !== id) throw new Error('Announcement is no longer active');
67
- const now = new Date().toISOString();
68
- return save({ ...value, announcement: null, announcements: value.announcements.map(item => item.status === 'active'
69
- ? { ...item, status: 'withdrawn', updatedAt: now } : item) });
70
- },
71
- deleteAnnouncement(id) {
72
- if (typeof id !== 'string' || !value.announcements.some(item => item.id === id)) throw new Error('Announcement not found');
73
- if (value.announcement?.id === id) throw new Error('Withdraw the current announcement before deleting it');
74
- return save({ ...value, announcements: value.announcements.filter(item => item.id !== id) });
75
- },
76
- };
77
- }
78
-
79
- function validate(source) {
80
- if (!source || ![1, 2, 3].includes(source.version) || !Number.isSafeInteger(source.maxRelaySessions) ||
81
- source.maxRelaySessions < 1 || source.maxRelaySessions > 256) throw new Error('Invalid relay admin state');
82
- const announcement = source.announcement == null ? null : validateAnnouncement(source.announcement);
83
- const announcements = source.version === 1 ? (announcement ? [announcement] : []) : source.announcements;
84
- if (!Array.isArray(announcements)) throw new Error('Invalid relay announcement history');
85
- const validated = source.version === 3 ? announcements.map(validateRecord) : announcements.map(item => {
86
- const old = validateAnnouncement(item);
87
- return { ...old, createdAt: old.publishedAt, updatedAt: old.publishedAt,
88
- status: old.id === announcement?.id ? 'active' : 'withdrawn' };
89
- });
90
- const active = validated.filter(item => item.status === 'active');
91
- if (new Set(validated.map(item => item.id)).size !== validated.length || active.length > 1 ||
92
- (active.length === 0) !== (announcement === null) ||
93
- (announcement && !active.some(item => item.id === announcement.id &&
94
- item.title === announcement.title && item.message === announcement.message && item.publishedAt === announcement.publishedAt))) {
95
- throw new Error('Invalid relay announcement history');
96
- }
97
- return { version: 3, maxRelaySessions: source.maxRelaySessions, announcement, announcements: validated };
98
- }
99
-
100
- function validateAnnouncement(item) {
101
- if (!item || !/^[\w-]{16,128}$/.test(item.id) || typeof item.publishedAt !== 'string' ||
102
- !Number.isFinite(Date.parse(item.publishedAt))) throw new Error('Invalid relay announcement');
103
- return { id: item.id, title: clean(item.title, 80), message: clean(item.message, 2000), publishedAt: item.publishedAt };
104
- }
105
-
106
- function validateRecord(item) {
107
- if (!item || !/^[\w-]{16,128}$/.test(item.id) || !['draft', 'active', 'withdrawn'].includes(item.status) ||
108
- typeof item.createdAt !== 'string' || !Number.isFinite(Date.parse(item.createdAt)) ||
109
- typeof item.updatedAt !== 'string' || !Number.isFinite(Date.parse(item.updatedAt)) ||
110
- (item.status === 'draft' ? item.publishedAt !== null :
111
- typeof item.publishedAt !== 'string' || !Number.isFinite(Date.parse(item.publishedAt)))) {
112
- throw new Error('Invalid relay announcement record');
113
- }
114
- return { id: item.id, title: clean(item.title, 80), message: clean(item.message, 2000),
115
- createdAt: item.createdAt, updatedAt: item.updatedAt, publishedAt: item.publishedAt, status: item.status };
116
- }
117
-
118
- function publicAnnouncement(record) {
119
- return { id: record.id, title: record.title, message: record.message, publishedAt: record.publishedAt };
120
- }
121
-
122
- function clean(value, limit) {
123
- if (typeof value !== 'string' || !value.trim() || value.trim().length > limit || /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(value)) {
124
- throw new Error('Invalid announcement text');
125
- }
126
- return value.trim();
127
- }
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+
5
+ // 80 title + 2000 body UTF-16 units fit even when every unit uses a six-byte JSON escape.
6
+ export const MAX_ADMIN_REQUEST_BYTES = 16 * 1024;
7
+
8
+ export function relayAdminState(file, initialMaxRelaySessions, { initial, persist } = {}) {
9
+ let value = initial ? validate(initial) : { version: 3, maxRelaySessions: initialMaxRelaySessions, announcement: null, announcements: [] };
10
+ if (file && fs.existsSync(file)) value = validate(JSON.parse(fs.readFileSync(file, 'utf8')));
11
+
12
+ async function save(next) {
13
+ const validated = validate(next);
14
+ if (persist) await persist(validated);
15
+ if (!file) { value = validated; return value; }
16
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
17
+ const temporary = `${file}.${randomUUID()}.tmp`;
18
+ let descriptor;
19
+ try {
20
+ descriptor = fs.openSync(temporary, 'wx', 0o600);
21
+ fs.writeFileSync(descriptor, JSON.stringify(validated));
22
+ fs.fsyncSync(descriptor); fs.closeSync(descriptor); descriptor = undefined;
23
+ fs.renameSync(temporary, file);
24
+ if (process.platform !== 'win32') {
25
+ const directory = fs.openSync(path.dirname(file), 'r');
26
+ try { fs.fsyncSync(directory); } finally { fs.closeSync(directory); }
27
+ }
28
+ } finally {
29
+ if (descriptor !== undefined) fs.closeSync(descriptor);
30
+ fs.rmSync(temporary, { force: true });
31
+ }
32
+ value = validated;
33
+ return value;
34
+ }
35
+
36
+ return {
37
+ read: () => structuredClone(value),
38
+ setMaxRelaySessions(maxRelaySessions) { return save({ ...value, maxRelaySessions }); },
39
+ createAnnouncement(title, message, publish = false) {
40
+ const now = new Date().toISOString();
41
+ const record = { id: randomUUID(), title: clean(title, 80), message: clean(message, 2000),
42
+ createdAt: now, updatedAt: now, publishedAt: publish ? now : null, status: publish ? 'active' : 'draft' };
43
+ const announcements = value.announcements.map(item => publish && item.status === 'active'
44
+ ? { ...item, status: 'withdrawn', updatedAt: now } : item);
45
+ return save({ ...value, announcement: publish ? publicAnnouncement(record) : value.announcement,
46
+ announcements: [record, ...announcements] });
47
+ },
48
+ updateDraft(id, title, message) {
49
+ const existing = value.announcements.find(item => item.id === id);
50
+ if (!existing || existing.status !== 'draft') throw new Error('Only drafts can be edited');
51
+ return save({ ...value, announcements: value.announcements.map(item => item.id === id
52
+ ? { ...item, title: clean(title, 80), message: clean(message, 2000), updatedAt: new Date().toISOString() } : item) });
53
+ },
54
+ publishDraft(id, title, message) {
55
+ const existing = value.announcements.find(item => item.id === id);
56
+ if (!existing || existing.status !== 'draft') throw new Error('Only drafts can be published');
57
+ const now = new Date().toISOString();
58
+ const published = { ...existing, title: title === undefined ? existing.title : clean(title, 80),
59
+ message: message === undefined ? existing.message : clean(message, 2000),
60
+ publishedAt: now, updatedAt: now, status: 'active' };
61
+ return save({ ...value, announcement: publicAnnouncement(published),
62
+ announcements: value.announcements.map(item => item.id === id ? published : item.status === 'active'
63
+ ? { ...item, status: 'withdrawn', updatedAt: now } : item) });
64
+ },
65
+ withdraw(id) {
66
+ if (!value.announcement || value.announcement.id !== id) throw new Error('Announcement is no longer active');
67
+ const now = new Date().toISOString();
68
+ return save({ ...value, announcement: null, announcements: value.announcements.map(item => item.status === 'active'
69
+ ? { ...item, status: 'withdrawn', updatedAt: now } : item) });
70
+ },
71
+ deleteAnnouncement(id) {
72
+ if (typeof id !== 'string' || !value.announcements.some(item => item.id === id)) throw new Error('Announcement not found');
73
+ if (value.announcement?.id === id) throw new Error('Withdraw the current announcement before deleting it');
74
+ return save({ ...value, announcements: value.announcements.filter(item => item.id !== id) });
75
+ },
76
+ };
77
+ }
78
+
79
+ function validate(source) {
80
+ if (!source || ![1, 2, 3].includes(source.version) || !Number.isSafeInteger(source.maxRelaySessions) ||
81
+ source.maxRelaySessions < 1 || source.maxRelaySessions > 256) throw new Error('Invalid relay admin state');
82
+ const announcement = source.announcement == null ? null : validateAnnouncement(source.announcement);
83
+ const announcements = source.version === 1 ? (announcement ? [announcement] : []) : source.announcements;
84
+ if (!Array.isArray(announcements)) throw new Error('Invalid relay announcement history');
85
+ const validated = source.version === 3 ? announcements.map(validateRecord) : announcements.map(item => {
86
+ const old = validateAnnouncement(item);
87
+ return { ...old, createdAt: old.publishedAt, updatedAt: old.publishedAt,
88
+ status: old.id === announcement?.id ? 'active' : 'withdrawn' };
89
+ });
90
+ const active = validated.filter(item => item.status === 'active');
91
+ if (new Set(validated.map(item => item.id)).size !== validated.length || active.length > 1 ||
92
+ (active.length === 0) !== (announcement === null) ||
93
+ (announcement && !active.some(item => item.id === announcement.id &&
94
+ item.title === announcement.title && item.message === announcement.message && item.publishedAt === announcement.publishedAt))) {
95
+ throw new Error('Invalid relay announcement history');
96
+ }
97
+ return { version: 3, maxRelaySessions: source.maxRelaySessions, announcement, announcements: validated };
98
+ }
99
+
100
+ function validateAnnouncement(item) {
101
+ if (!item || !/^[\w-]{16,128}$/.test(item.id) || typeof item.publishedAt !== 'string' ||
102
+ !Number.isFinite(Date.parse(item.publishedAt))) throw new Error('Invalid relay announcement');
103
+ return { id: item.id, title: clean(item.title, 80), message: clean(item.message, 2000), publishedAt: item.publishedAt };
104
+ }
105
+
106
+ function validateRecord(item) {
107
+ if (!item || !/^[\w-]{16,128}$/.test(item.id) || !['draft', 'active', 'withdrawn'].includes(item.status) ||
108
+ typeof item.createdAt !== 'string' || !Number.isFinite(Date.parse(item.createdAt)) ||
109
+ typeof item.updatedAt !== 'string' || !Number.isFinite(Date.parse(item.updatedAt)) ||
110
+ (item.status === 'draft' ? item.publishedAt !== null :
111
+ typeof item.publishedAt !== 'string' || !Number.isFinite(Date.parse(item.publishedAt)))) {
112
+ throw new Error('Invalid relay announcement record');
113
+ }
114
+ return { id: item.id, title: clean(item.title, 80), message: clean(item.message, 2000),
115
+ createdAt: item.createdAt, updatedAt: item.updatedAt, publishedAt: item.publishedAt, status: item.status };
116
+ }
117
+
118
+ function publicAnnouncement(record) {
119
+ return { id: record.id, title: record.title, message: record.message, publishedAt: record.publishedAt };
120
+ }
121
+
122
+ function clean(value, limit) {
123
+ if (typeof value !== 'string' || !value.trim() || value.trim().length > limit || /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(value)) {
124
+ throw new Error('Invalid announcement text');
125
+ }
126
+ return value.trim();
127
+ }