@walkhi/code-relax 0.1.0-beta.50 → 0.1.0-beta.59

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 (101) hide show
  1. package/README.md +105 -105
  2. package/dist/bin/cleanup-accounts.mjs +63 -63
  3. package/dist/bin/codex-remote.mjs +403 -403
  4. package/dist/bin/publish-android-release.mjs +63 -63
  5. package/dist/bin/release-upload-server.mjs +190 -190
  6. package/dist/bin/set-relay-admin-password.mjs +11 -0
  7. package/dist/build-platform.json +1 -1
  8. package/dist/get-shared-service-url.ps1 +14 -14
  9. package/dist/shared/code-browser.cjs +159 -159
  10. package/dist/shared/file-changes.cjs +31 -31
  11. package/dist/shared/file-citations.cjs +117 -117
  12. package/dist/shared/file-mentions.cjs +92 -92
  13. package/dist/shared/message-delivery.cjs +115 -115
  14. package/dist/shared/p2p-file-channel.cjs +87 -87
  15. package/dist/shared/release-version.cjs +31 -0
  16. package/dist/shared/server-requests.cjs +311 -311
  17. package/dist/shared/timeline-sync.cjs +173 -173
  18. package/dist/skills/code-relax/SKILL.md +22 -22
  19. package/dist/src/bounded-process-log.mjs +63 -63
  20. package/dist/src/celsius-client.mjs +107 -107
  21. package/dist/src/dots-approval-timeline.mjs +99 -99
  22. package/dist/src/dots-avatar.mjs +42 -42
  23. package/dist/src/dots-chat-projection.mjs +50 -50
  24. package/dist/src/dots-chat-server.mjs +483 -483
  25. package/dist/src/dots-message-images.mjs +319 -319
  26. package/dist/src/dots-profile.mjs +77 -77
  27. package/dist/src/dots-room-activity.mjs +172 -172
  28. package/dist/src/dots-room-client.mjs +137 -135
  29. package/dist/src/dots-room-projection.mjs +94 -94
  30. package/dist/src/dots-schedules.mjs +66 -66
  31. package/dist/src/dots-summary-tasks.mjs +32 -32
  32. package/dist/src/dots-task-catalog.mjs +430 -430
  33. package/dist/src/file-transfer-range.mjs +22 -22
  34. package/dist/src/file-upload-session.mjs +146 -130
  35. package/dist/src/hosted-history-app-server.mjs +103 -103
  36. package/dist/src/hosted-history-cache.mjs +113 -113
  37. package/dist/src/lan-socket-server.mjs +338 -266
  38. package/dist/src/managed-app-server.mjs +373 -373
  39. package/dist/src/managed-endpoint.mjs +25 -25
  40. package/dist/src/message-upload-files.mjs +109 -109
  41. package/dist/src/onboarding.mjs +12 -12
  42. package/dist/src/platform/README.md +1 -1
  43. package/dist/src/platform/codex-protocol.mjs +46 -46
  44. package/dist/src/platform/desktop-project-store.mjs +219 -208
  45. package/dist/src/platform/linux/desktop-host.mjs +87 -87
  46. package/dist/src/platform/linux/process.mjs +53 -53
  47. package/dist/src/platform/linux/system.mjs +50 -50
  48. package/dist/src/platform/linux/user-environment.mjs +9 -9
  49. package/dist/src/platform/macos/codex-runtime.mjs +125 -125
  50. package/dist/src/platform/macos/desktop-host.mjs +86 -86
  51. package/dist/src/platform/macos/user-environment.mjs +9 -9
  52. package/dist/src/platform/native-package.mjs +58 -58
  53. package/dist/src/platform/windows/activate-desktop.ps1 +49 -49
  54. package/dist/src/platform/windows/codex-runtime.mjs +215 -215
  55. package/dist/src/platform/windows/credential-store.mjs +27 -27
  56. package/dist/src/platform/windows/credential-store.ps1 +49 -49
  57. package/dist/src/platform/windows/desktop-host.mjs +65 -65
  58. package/dist/src/platform/windows/desktop-info.ps1 +38 -38
  59. package/dist/src/platform/windows/desktop-project-registration.mjs +4 -4
  60. package/dist/src/platform/windows/desktop-shortcuts.ps1 +14 -14
  61. package/dist/src/platform/windows/dots-profile.mjs +49 -49
  62. package/dist/src/platform/windows/launch-worker.mjs +41 -41
  63. package/dist/src/platform/windows/project-locations.mjs +16 -16
  64. package/dist/src/platform/windows/service-host.mjs +35 -35
  65. package/dist/src/platform/windows/user-environment.mjs +52 -52
  66. package/dist/src/project-management.mjs +58 -58
  67. package/dist/src/runtime-discovery.mjs +47 -47
  68. package/dist/src/self-relay/account-cleanup.mjs +30 -30
  69. package/dist/src/self-relay/admin-auth.mjs +59 -0
  70. package/dist/src/self-relay/admin-login-page.mjs +7 -0
  71. package/dist/src/self-relay/admin-page.mjs +87 -80
  72. package/dist/src/self-relay/admin-server.mjs +49 -18
  73. package/dist/src/self-relay/admin-style.mjs +58 -14
  74. package/dist/src/self-relay/admin-workstations-page.mjs +95 -0
  75. package/dist/src/self-relay/admin-workstations.mjs +143 -0
  76. package/dist/src/self-relay/connection-reporting.mjs +38 -0
  77. package/dist/src/self-relay/connector.mjs +444 -439
  78. package/dist/src/self-relay/demo.mjs +31 -31
  79. package/dist/src/self-relay/diagnostics.mjs +54 -54
  80. package/dist/src/self-relay/email-accounts.mjs +183 -183
  81. package/dist/src/self-relay/file-wire.mjs +13 -13
  82. package/dist/src/self-relay/lifecycle.mjs +11 -0
  83. package/dist/src/self-relay/p2p-probe.mjs +168 -168
  84. package/dist/src/self-relay/phone-connections.mjs +55 -0
  85. package/dist/src/self-relay/privacy-page.mjs +26 -26
  86. package/dist/src/self-relay/public-releases.mjs +67 -67
  87. package/dist/src/self-relay/rtc-file-bridge.mjs +46 -41
  88. package/dist/src/self-relay/server.mjs +63 -3
  89. package/dist/src/self-relay/usage-meter.mjs +141 -141
  90. package/dist/src/server.mjs +23 -9
  91. package/dist/src/service-doctor.mjs +148 -148
  92. package/dist/src/service-install.mjs +97 -97
  93. package/dist/src/service-lifecycle.mjs +139 -139
  94. package/dist/src/service-update.mjs +6 -3
  95. package/dist/src/shared-app-server.mjs +683 -666
  96. package/dist/src/shared-catalog.mjs +132 -132
  97. package/dist/src/shared-thread-stream.mjs +165 -165
  98. package/dist/src/usage-reset.mjs +17 -17
  99. package/dist/src/workstation-downloads.mjs +120 -120
  100. package/package.json +4 -4
  101. package/tools/postinstall.mjs +186 -186
@@ -1,52 +1,52 @@
1
- import path from 'node:path';
2
- import fs from 'node:fs';
3
- import { readRecord, writeRecord } from '../../service-lifecycle.mjs';
4
- import { runScript } from './desktop-host.mjs';
5
- import { managedEndpointUrl } from '../../managed-endpoint.mjs';
6
-
7
- const ownershipFile = 'managed-environment.json';
8
-
9
- export async function readManagedEnvironment() {
10
- return JSON.parse(await runScript('user-environment.ps1', ['-Action', 'Read']));
11
- }
12
-
13
- export async function configureManagedEnvironment(stateRoot, url) {
14
- const current = await readManagedEnvironment();
15
- if (!managedEndpointUrl(url)) throw new Error('未确认 Codex 内核地址,未修改环境变量。');
16
- const ownershipPath = path.join(stateRoot, ownershipFile);
17
- const ownership = readRecord(ownershipPath);
18
- if (current.forceCliUser === '1' || current.forceCliMachine === '1') {
19
- throw new Error('CODEX_APP_SERVER_FORCE_CLI=1 会让 Codex 忽略当前内核地址;未修改该配置。');
20
- }
21
- if (current.userValue && current.userValue !== url && current.userValue !== ownership?.value) {
22
- throw new Error(`用户已有 CODEX_APP_SERVER_WS_URL=${current.userValue};未覆盖该配置。`);
23
- }
24
- if (current.userValue === url) {
25
- const owned = ownership?.value === url;
26
- return { configured: true, value: current.userValue, changed: false, owned };
27
- }
28
- const applied = JSON.parse(await runScript('user-environment.ps1', ['-Action', 'Set', '-Value', url]));
29
- if (applied.userValue !== url) throw new Error('CODEX_APP_SERVER_WS_URL 写入后读回不一致。');
30
- writeRecord(ownershipPath, { name: 'CODEX_APP_SERVER_WS_URL', value: url,
31
- configuredAt: new Date().toISOString() });
32
- return { configured: true, value: applied.userValue, changed: true, owned: true };
33
- }
34
-
35
- export async function clearManagedEnvironment(stateRoot) {
36
- const ownershipPath = path.join(stateRoot, ownershipFile);
37
- const ownership = readRecord(ownershipPath);
38
- const current = await readManagedEnvironment();
39
- if (!current.userValue) {
40
- fs.rmSync(ownershipPath, { force: true });
41
- return { removed: false, preserved: false, reason: 'already-empty' };
42
- }
43
- if (!managedEndpointUrl(ownership?.value) || current.userValue !== ownership.value) {
44
- fs.rmSync(ownershipPath, { force: true });
45
- return { removed: false, preserved: true, value: current.userValue,
46
- reason: managedEndpointUrl(ownership?.value) ? 'user-modified' : 'not-owned' };
47
- }
48
- const result = JSON.parse(await runScript('user-environment.ps1', ['-Action', 'Clear']));
49
- if (result.userValue) throw new Error('CODEX_APP_SERVER_WS_URL 清理后读回仍不为空。');
50
- fs.rmSync(ownershipPath, { force: true });
51
- return { removed: true, preserved: false, reason: 'removed' };
52
- }
1
+ import path from 'node:path';
2
+ import fs from 'node:fs';
3
+ import { readRecord, writeRecord } from '../../service-lifecycle.mjs';
4
+ import { runScript } from './desktop-host.mjs';
5
+ import { managedEndpointUrl } from '../../managed-endpoint.mjs';
6
+
7
+ const ownershipFile = 'managed-environment.json';
8
+
9
+ export async function readManagedEnvironment() {
10
+ return JSON.parse(await runScript('user-environment.ps1', ['-Action', 'Read']));
11
+ }
12
+
13
+ export async function configureManagedEnvironment(stateRoot, url) {
14
+ const current = await readManagedEnvironment();
15
+ if (!managedEndpointUrl(url)) throw new Error('未确认 Codex 内核地址,未修改环境变量。');
16
+ const ownershipPath = path.join(stateRoot, ownershipFile);
17
+ const ownership = readRecord(ownershipPath);
18
+ if (current.forceCliUser === '1' || current.forceCliMachine === '1') {
19
+ throw new Error('CODEX_APP_SERVER_FORCE_CLI=1 会让 Codex 忽略当前内核地址;未修改该配置。');
20
+ }
21
+ if (current.userValue && current.userValue !== url && current.userValue !== ownership?.value) {
22
+ throw new Error(`用户已有 CODEX_APP_SERVER_WS_URL=${current.userValue};未覆盖该配置。`);
23
+ }
24
+ if (current.userValue === url) {
25
+ const owned = ownership?.value === url;
26
+ return { configured: true, value: current.userValue, changed: false, owned };
27
+ }
28
+ const applied = JSON.parse(await runScript('user-environment.ps1', ['-Action', 'Set', '-Value', url]));
29
+ if (applied.userValue !== url) throw new Error('CODEX_APP_SERVER_WS_URL 写入后读回不一致。');
30
+ writeRecord(ownershipPath, { name: 'CODEX_APP_SERVER_WS_URL', value: url,
31
+ configuredAt: new Date().toISOString() });
32
+ return { configured: true, value: applied.userValue, changed: true, owned: true };
33
+ }
34
+
35
+ export async function clearManagedEnvironment(stateRoot) {
36
+ const ownershipPath = path.join(stateRoot, ownershipFile);
37
+ const ownership = readRecord(ownershipPath);
38
+ const current = await readManagedEnvironment();
39
+ if (!current.userValue) {
40
+ fs.rmSync(ownershipPath, { force: true });
41
+ return { removed: false, preserved: false, reason: 'already-empty' };
42
+ }
43
+ if (!managedEndpointUrl(ownership?.value) || current.userValue !== ownership.value) {
44
+ fs.rmSync(ownershipPath, { force: true });
45
+ return { removed: false, preserved: true, value: current.userValue,
46
+ reason: managedEndpointUrl(ownership?.value) ? 'user-modified' : 'not-owned' };
47
+ }
48
+ const result = JSON.parse(await runScript('user-environment.ps1', ['-Action', 'Clear']));
49
+ if (result.userValue) throw new Error('CODEX_APP_SERVER_WS_URL 清理后读回仍不为空。');
50
+ fs.rmSync(ownershipPath, { force: true });
51
+ return { removed: true, preserved: false, reason: 'removed' };
52
+ }
@@ -1,59 +1,59 @@
1
- import path from 'node:path';
2
-
3
- const fail = (message, statusCode = 400) => { throw Object.assign(new Error(message), { statusCode }); };
4
- const pathKey = value => process.platform === 'win32' ? value.toLowerCase() : value;
5
-
6
- function absoluteDirectory(value) {
7
- if (typeof value !== 'string' || !value.trim() || !path.isAbsolute(value.trim())) fail('请输入电脑上的绝对文件夹路径。');
8
- return path.normalize(value.trim());
9
- }
10
-
11
- async function directory(rpc, value) {
12
- const target = absoluteDirectory(value);
13
- const stat = await rpc('fs/getMetadata', { path: target });
14
- if (!stat.isDirectory) fail(`此路径不是文件夹:${target}`);
15
- return target;
16
- }
17
-
18
- function folderName(value) {
19
- if (typeof value !== 'string' || !value.trim() || /[<>:"/\\|?*\x00-\x1f]/.test(value) || /[ .]$/.test(value)
20
- || /^(?:con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])(?:\.|$)/i.test(value) || ['.', '..'].includes(value)) {
21
- fail('文件夹名称无效,请勿使用路径分隔符或系统保留名称。');
22
- }
23
- return value;
24
- }
25
-
26
- export async function projectRoots(rpc, roots) {
27
- if (!Array.isArray(roots)) fail('项目文件夹列表无效。');
28
- const result = [], seen = new Set();
29
- for (const root of roots) {
30
- const target = await directory(rpc, root?.path);
31
- const key = pathKey(target);
32
- if (seen.has(key)) fail(`重复的项目文件夹:${target}`);
33
- seen.add(key); result.push({ path: target });
34
- }
35
- return result;
36
- }
37
-
38
- export function projectSummary(project) {
39
- return { projectId: project.id, name: project.name, label: project.name, roots: project.roots,
40
- path: project.roots[0]?.path || '', cwd: project.roots[0]?.path || '', hostId: 'local' };
41
- }
42
-
43
- export async function browseProjectFolders(rpc, locations, value) {
44
- const target = absoluteDirectory(value || locations?.home);
45
- const entries = (await rpc('fs/readDirectory', { path: target })).entries
46
- .filter(entry => entry.isDirectory).map(entry => ({ name: entry.fileName, path: path.join(target, entry.fileName) }))
47
- .sort((a, b) => a.name.localeCompare(b.name, 'zh-CN', { numeric: true }));
48
- return { path: target, parent: path.dirname(target) === target ? null : path.dirname(target), entries, ...(locations || {}) };
49
- }
50
-
51
- export async function createProjectFolder(rpc, body) {
52
- const parent = await directory(rpc, body.parent);
53
- const name = folderName(body.name);
54
- const entries = (await rpc('fs/readDirectory', { path: parent })).entries;
55
- if (entries.some(entry => pathKey(entry.fileName) === pathKey(name))) fail('同名文件夹已存在,请选择它或使用其他名称。', 409);
56
- const target = path.join(parent, name);
57
- await rpc('fs/createDirectory', { path: target, recursive: false });
58
- return { path: target };
1
+ import path from 'node:path';
2
+
3
+ const fail = (message, statusCode = 400) => { throw Object.assign(new Error(message), { statusCode }); };
4
+ const pathKey = value => process.platform === 'win32' ? value.toLowerCase() : value;
5
+
6
+ function absoluteDirectory(value) {
7
+ if (typeof value !== 'string' || !value.trim() || !path.isAbsolute(value.trim())) fail('请输入电脑上的绝对文件夹路径。');
8
+ return path.normalize(value.trim());
9
+ }
10
+
11
+ async function directory(rpc, value) {
12
+ const target = absoluteDirectory(value);
13
+ const stat = await rpc('fs/getMetadata', { path: target });
14
+ if (!stat.isDirectory) fail(`此路径不是文件夹:${target}`);
15
+ return target;
16
+ }
17
+
18
+ function folderName(value) {
19
+ if (typeof value !== 'string' || !value.trim() || /[<>:"/\\|?*\x00-\x1f]/.test(value) || /[ .]$/.test(value)
20
+ || /^(?:con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])(?:\.|$)/i.test(value) || ['.', '..'].includes(value)) {
21
+ fail('文件夹名称无效,请勿使用路径分隔符或系统保留名称。');
22
+ }
23
+ return value;
24
+ }
25
+
26
+ export async function projectRoots(rpc, roots) {
27
+ if (!Array.isArray(roots)) fail('项目文件夹列表无效。');
28
+ const result = [], seen = new Set();
29
+ for (const root of roots) {
30
+ const target = await directory(rpc, root?.path);
31
+ const key = pathKey(target);
32
+ if (seen.has(key)) fail(`重复的项目文件夹:${target}`);
33
+ seen.add(key); result.push({ path: target });
34
+ }
35
+ return result;
36
+ }
37
+
38
+ export function projectSummary(project) {
39
+ return { projectId: project.id, name: project.name, label: project.name, roots: project.roots,
40
+ path: project.roots[0]?.path || '', cwd: project.roots[0]?.path || '', hostId: 'local' };
41
+ }
42
+
43
+ export async function browseProjectFolders(rpc, locations, value) {
44
+ const target = absoluteDirectory(value || locations?.home);
45
+ const entries = (await rpc('fs/readDirectory', { path: target })).entries
46
+ .filter(entry => entry.isDirectory).map(entry => ({ name: entry.fileName, path: path.join(target, entry.fileName) }))
47
+ .sort((a, b) => a.name.localeCompare(b.name, 'zh-CN', { numeric: true }));
48
+ return { path: target, parent: path.dirname(target) === target ? null : path.dirname(target), entries, ...(locations || {}) };
49
+ }
50
+
51
+ export async function createProjectFolder(rpc, body) {
52
+ const parent = await directory(rpc, body.parent);
53
+ const name = folderName(body.name);
54
+ const entries = (await rpc('fs/readDirectory', { path: parent })).entries;
55
+ if (entries.some(entry => pathKey(entry.fileName) === pathKey(name))) fail('同名文件夹已存在,请选择它或使用其他名称。', 409);
56
+ const target = path.join(parent, name);
57
+ await rpc('fs/createDirectory', { path: target, recursive: false });
58
+ return { path: target };
59
59
  }
@@ -1,47 +1,47 @@
1
- import { discoverCodex, resolveCodexRuntime, runtimeFingerprint } from './platform/codex-runtime.mjs';
2
-
3
- // Advisory discovery is independent of execution health. Failure here must never
4
- // disable an already running app-server or cause an automatic process restart.
5
- export class RuntimeDiscovery {
6
- constructor({ stateRoot, host, url, interval = 60000 }) {
7
- Object.assign(this, { stateRoot, host, url, interval });
8
- this.state = {};
9
- }
10
- start() { this.stopped = false; void this.tick(); }
11
- stop() { this.stopped = true; clearTimeout(this.timer); }
12
- async sample() {
13
- const errors = [];
14
- try {
15
- const candidates = discoverCodex();
16
- const signature = JSON.stringify(candidates.map(candidate => {
17
- try { return [candidate.executable, runtimeFingerprint(candidate.executable)]; }
18
- catch (error) { return [candidate.executable, error.message]; }
19
- }));
20
- if (signature !== this.signature) {
21
- await resolveCodexRuntime(this.stateRoot, { refresh: true });
22
- this.signature = signature;
23
- }
24
- } catch (error) { errors.push(error.message); }
25
- try {
26
- if (this.host.desktopIntegration === false) {
27
- this.state = { desktop: { state: 'unsupported', integrationSupported: false }, checkedAt: new Date().toISOString() };
28
- if (errors.length) this.state.discoveryError = errors.join('\n');
29
- return this.state;
30
- }
31
- const desktop = await this.host.inspect();
32
- const attached = desktop.running && await this.host.sharedServiceUrl() === (typeof this.url === 'function' ? this.url() : this.url);
33
- this.state = { desktop: { state: !desktop.running ? 'stopped' : attached ? 'managed' : 'ordinary',
34
- installed: Boolean(desktop.desktop), version: desktop.version || '', attachedToManagedServer: Boolean(attached) },
35
- desktopRestartRecommended: Boolean(desktop.running && !attached), checkedAt: new Date().toISOString() };
36
- } catch (error) {
37
- errors.push(error.message);
38
- this.state = { checkedAt: new Date().toISOString() };
39
- }
40
- if (errors.length) this.state.discoveryError = errors.join('\n');
41
- return this.state;
42
- }
43
- async tick() {
44
- await this.sample();
45
- if (!this.stopped) { this.timer = setTimeout(() => this.tick(), this.interval); this.timer.unref?.(); }
46
- }
47
- }
1
+ import { discoverCodex, resolveCodexRuntime, runtimeFingerprint } from './platform/codex-runtime.mjs';
2
+
3
+ // Advisory discovery is independent of execution health. Failure here must never
4
+ // disable an already running app-server or cause an automatic process restart.
5
+ export class RuntimeDiscovery {
6
+ constructor({ stateRoot, host, url, interval = 60000 }) {
7
+ Object.assign(this, { stateRoot, host, url, interval });
8
+ this.state = {};
9
+ }
10
+ start() { this.stopped = false; void this.tick(); }
11
+ stop() { this.stopped = true; clearTimeout(this.timer); }
12
+ async sample() {
13
+ const errors = [];
14
+ try {
15
+ const candidates = discoverCodex();
16
+ const signature = JSON.stringify(candidates.map(candidate => {
17
+ try { return [candidate.executable, runtimeFingerprint(candidate.executable)]; }
18
+ catch (error) { return [candidate.executable, error.message]; }
19
+ }));
20
+ if (signature !== this.signature) {
21
+ await resolveCodexRuntime(this.stateRoot, { refresh: true });
22
+ this.signature = signature;
23
+ }
24
+ } catch (error) { errors.push(error.message); }
25
+ try {
26
+ if (this.host.desktopIntegration === false) {
27
+ this.state = { desktop: { state: 'unsupported', integrationSupported: false }, checkedAt: new Date().toISOString() };
28
+ if (errors.length) this.state.discoveryError = errors.join('\n');
29
+ return this.state;
30
+ }
31
+ const desktop = await this.host.inspect();
32
+ const attached = desktop.running && await this.host.sharedServiceUrl() === (typeof this.url === 'function' ? this.url() : this.url);
33
+ this.state = { desktop: { state: !desktop.running ? 'stopped' : attached ? 'managed' : 'ordinary',
34
+ installed: Boolean(desktop.desktop), version: desktop.version || '', hasWindow: desktop.hasWindow, attachedToManagedServer: Boolean(attached) },
35
+ desktopRestartRecommended: Boolean(desktop.running && !attached), checkedAt: new Date().toISOString() };
36
+ } catch (error) {
37
+ errors.push(error.message);
38
+ this.state = { checkedAt: new Date().toISOString() };
39
+ }
40
+ if (errors.length) this.state.discoveryError = errors.join('\n');
41
+ return this.state;
42
+ }
43
+ async tick() {
44
+ await this.sample();
45
+ if (!this.stopped) { this.timer = setTimeout(() => this.tick(), this.interval); this.timer.unref?.(); }
46
+ }
47
+ }
@@ -1,30 +1,30 @@
1
- // Process one account per transaction; foreign keys keep incomplete deletion atomic.
2
- export async function purgeCancelledAccounts(pool, scopeHash) {
3
- let purged = 0;
4
- for (;;) {
5
- const db = await pool.getConnection();
6
- try {
7
- await db.beginTransaction();
8
- const [rows] = await db.query(`SELECT id FROM users WHERE status='cancelled'
9
- AND cancelled_at <= UTC_TIMESTAMP(3) - INTERVAL 1 DAY
10
- ORDER BY cancelled_at LIMIT 1 FOR UPDATE`);
11
- if (!rows.length) { await db.commit(); return purged; }
12
- const id = rows[0].id;
13
- const [identities] = await db.execute("SELECT normalized_value FROM user_login_identities WHERE user_id=? AND kind='email'", [id]);
14
- for (const { normalized_value: email } of identities) {
15
- await db.execute('DELETE FROM email_login_challenges WHERE email=?', [email]);
16
- await db.execute('DELETE FROM email_send_limits WHERE scope_hash=?', [scopeHash(`email:${email}`)]);
17
- await db.execute('DELETE FROM relay_admin_emails WHERE email=?', [email]);
18
- }
19
- for (const table of ['user_sessions', 'relay_usage_actions', 'relay_usage_sessions',
20
- 'relay_usage_user_days', 'relay_usage_budgets', 'relay_usage_entitlements',
21
- 'relay_usage_totals', 'user_login_identities']) {
22
- await db.execute(`DELETE FROM ${table} WHERE user_id=?`, [id]);
23
- }
24
- await db.execute('UPDATE computers SET owner_user_id=NULL WHERE owner_user_id=?', [id]);
25
- await db.execute('DELETE FROM users WHERE id=?', [id]);
26
- await db.commit(); purged++;
27
- } catch (error) { await db.rollback(); throw error; }
28
- finally { db.release(); }
29
- }
30
- }
1
+ // Process one account per transaction; foreign keys keep incomplete deletion atomic.
2
+ export async function purgeCancelledAccounts(pool, scopeHash) {
3
+ let purged = 0;
4
+ for (;;) {
5
+ const db = await pool.getConnection();
6
+ try {
7
+ await db.beginTransaction();
8
+ const [rows] = await db.query(`SELECT id FROM users WHERE status='cancelled'
9
+ AND cancelled_at <= UTC_TIMESTAMP(3) - INTERVAL 1 DAY
10
+ ORDER BY cancelled_at LIMIT 1 FOR UPDATE`);
11
+ if (!rows.length) { await db.commit(); return purged; }
12
+ const id = rows[0].id;
13
+ const [identities] = await db.execute("SELECT normalized_value FROM user_login_identities WHERE user_id=? AND kind='email'", [id]);
14
+ for (const { normalized_value: email } of identities) {
15
+ await db.execute('DELETE FROM email_login_challenges WHERE email=?', [email]);
16
+ await db.execute('DELETE FROM email_send_limits WHERE scope_hash=?', [scopeHash(`email:${email}`)]);
17
+ await db.execute('DELETE FROM relay_admin_emails WHERE email=?', [email]);
18
+ }
19
+ for (const table of ['user_sessions', 'relay_usage_actions', 'relay_usage_sessions',
20
+ 'relay_usage_user_days', 'relay_usage_budgets', 'relay_usage_entitlements',
21
+ 'relay_usage_totals', 'user_login_identities']) {
22
+ await db.execute(`DELETE FROM ${table} WHERE user_id=?`, [id]);
23
+ }
24
+ await db.execute('UPDATE computers SET owner_user_id=NULL WHERE owner_user_id=?', [id]);
25
+ await db.execute('DELETE FROM users WHERE id=?', [id]);
26
+ await db.commit(); purged++;
27
+ } catch (error) { await db.rollback(); throw error; }
28
+ finally { db.release(); }
29
+ }
30
+ }
@@ -0,0 +1,59 @@
1
+ import fs from 'node:fs';
2
+ import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
3
+ import { promisify } from 'node:util';
4
+
5
+ const derive = promisify(scrypt);
6
+ const COOKIE = '__Host-code-relax-admin';
7
+ const SESSION_MS = 12 * 60 * 60 * 1000;
8
+ const cookie = (value, seconds) => `${COOKIE}=${value}; Path=/; Secure; HttpOnly; SameSite=Strict; Max-Age=${seconds}`;
9
+
10
+ export async function passwordRecord(password) {
11
+ if (typeof password !== 'string' || password.length < 10 || Buffer.byteLength(password) > 256) {
12
+ throw new Error('Admin password must have at least 10 characters and at most 256 bytes');
13
+ }
14
+ const salt = randomBytes(16).toString('hex');
15
+ return { salt, hash: (await derive(password, salt, 64)).toString('hex') };
16
+ }
17
+
18
+ export function createAdminAuth(passwordFile) {
19
+ const { salt, hash } = JSON.parse(fs.readFileSync(passwordFile, 'utf8'));
20
+ if (!/^[a-f0-9]{32}$/.test(salt) || !/^[a-f0-9]{128}$/.test(hash)) throw new Error('Invalid admin password record');
21
+ const expected = Buffer.from(hash, 'hex');
22
+ const sessions = new Map();
23
+ let windowStart = 0, attempts = 0, verifying = false;
24
+ const token = request => (request.headers.cookie || '').split(';').map(value => value.trim())
25
+ .find(value => value.startsWith(`${COOKIE}=`))?.slice(COOKIE.length + 1);
26
+ function purge() {
27
+ for (const [id, expires] of sessions) if (expires <= Date.now()) sessions.delete(id);
28
+ }
29
+ return {
30
+ authenticated(request) { purge(); return sessions.has(token(request)); },
31
+ async login(password, request, response) {
32
+ const now = Date.now();
33
+ if (now - windowStart >= 60000) { windowStart = now; attempts = 0; }
34
+ // This is a single-owner console: bound both guessing and concurrent scrypt memory.
35
+ if (attempts >= 10 || verifying) {
36
+ response.setHeader('Retry-After', '60');
37
+ return { status: 429, error: '尝试过于频繁,请一分钟后重试' };
38
+ }
39
+ attempts++;
40
+ if (typeof password !== 'string' || Buffer.byteLength(password) > 256) return { status: 400, error: '请输入有效密码' };
41
+ verifying = true;
42
+ try {
43
+ const actual = await derive(password, salt, 64);
44
+ if (!timingSafeEqual(actual, expected)) return { status: 401, error: '密码不正确' };
45
+ purge();
46
+ sessions.delete(token(request));
47
+ if (sessions.size >= 32) sessions.delete(sessions.keys().next().value);
48
+ const id = randomBytes(32).toString('base64url');
49
+ sessions.set(id, Date.now() + SESSION_MS);
50
+ response.setHeader('Set-Cookie', cookie(id, SESSION_MS / 1000));
51
+ return { status: 200 };
52
+ } finally { verifying = false; }
53
+ },
54
+ logout(request, response) {
55
+ sessions.delete(token(request));
56
+ response.setHeader('Set-Cookie', cookie('', 0));
57
+ },
58
+ };
59
+ }
@@ -0,0 +1,7 @@
1
+ import { adminUiTokens, adminFooterStyles, adminAuthStyles } from './admin-style.mjs';
2
+
3
+ export function adminLoginPage() {
4
+ return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>登录 · Code Relax 管理</title><style>${adminUiTokens}${adminFooterStyles}${adminAuthStyles}</style></head>
5
+ <body class="admin-login"><main><form id="login" class="login-card"><h1>Code Relax 管理</h1><p>输入管理密码,进入后台。</p><label for="password">管理密码</label><input id="password" name="password" type="password" autocomplete="current-password" required maxlength="256" autofocus><button id="submit" type="submit">登录</button><p id="result" role="alert" aria-live="polite"></p></form></main><footer class="admin-footer">京ICP备2026027643号-2</footer>
6
+ <script>const form=document.getElementById('login'),button=document.getElementById('submit'),result=document.getElementById('result');form.addEventListener('submit',async event=>{event.preventDefault();if(button.disabled)return;button.disabled=true;result.textContent='正在登录…';try{const response=await fetch('/admin/login',{method:'POST',headers:{'Content-Type':'application/json','X-Code-Relax-Admin':'1'},body:JSON.stringify({password:form.password.value})});const body=await response.json();if(!response.ok)throw new Error(body.error||'登录失败');form.reset();location.replace('/')}catch(error){result.textContent=error.message}finally{button.disabled=false}});</script></body></html>`;
7
+ }