orbit-codex-bridge 0.1.6 → 0.1.8

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.
package/relay-store.mjs DELETED
@@ -1,368 +0,0 @@
1
- import { DatabaseSync } from 'node:sqlite';
2
- import crypto from 'node:crypto';
3
- import fs from 'node:fs';
4
- import os from 'node:os';
5
- import path from 'node:path';
6
- import { promisify } from 'node:util';
7
-
8
- const scrypt = promisify(crypto.scrypt);
9
-
10
- function safeCount(value, { min = 1, max = 100 } = {}) {
11
- const number = Number(value);
12
- if (!Number.isSafeInteger(number) || number < min || number > max) throw new Error('数量超出允许范围。');
13
- return number;
14
- }
15
-
16
- function safeString(value, label, max = 120) {
17
- if (typeof value !== 'string') throw new Error(`${label}格式无效。`);
18
- const result = value.trim();
19
- if (!result || result.length > max || /[\u0000-\u001f\u007f]/.test(result)) throw new Error(`${label}格式无效。`);
20
- return result;
21
- }
22
-
23
- function validPassword(value) {
24
- if(typeof value!=='string'||value.length<6||value.length>256||/[\u0000-\u001f\u007f]/.test(value))throw new Error('密码需要 6–256 个字符,不能包含控制字符。');
25
- return value;
26
- }
27
-
28
- export class RelayStore {
29
- constructor(filePath = process.env.ORBIT_RELAY_DB || path.join(os.homedir(), '.orbit', 'relay.sqlite')) {
30
- this.filePath = path.resolve(filePath);
31
- fs.mkdirSync(path.dirname(this.filePath), { recursive:true });
32
- this.db = new DatabaseSync(this.filePath);
33
- this.db.exec(`
34
- PRAGMA journal_mode=WAL;
35
- PRAGMA foreign_keys=ON;
36
- PRAGMA busy_timeout=5000;
37
- CREATE TABLE IF NOT EXISTS users (
38
- id TEXT PRIMARY KEY,
39
- username TEXT NOT NULL UNIQUE,
40
- display_name TEXT NOT NULL,
41
- password_salt TEXT NOT NULL,
42
- password_hash TEXT NOT NULL,
43
- status TEXT NOT NULL CHECK(status IN ('active','suspended')),
44
- plan TEXT NOT NULL,
45
- max_devices INTEGER NOT NULL,
46
- expires_at INTEGER,
47
- created_at INTEGER NOT NULL
48
- );
49
- CREATE TABLE IF NOT EXISTS devices (
50
- id TEXT PRIMARY KEY,
51
- user_id TEXT NOT NULL REFERENCES users(id),
52
- name TEXT NOT NULL,
53
- token_hash TEXT NOT NULL UNIQUE,
54
- status TEXT NOT NULL CHECK(status IN ('active','revoked')),
55
- created_at INTEGER NOT NULL,
56
- last_seen INTEGER
57
- );
58
- CREATE INDEX IF NOT EXISTS devices_by_user ON devices(user_id, created_at);
59
- CREATE TABLE IF NOT EXISTS sessions (
60
- token_hash TEXT PRIMARY KEY,
61
- user_id TEXT REFERENCES users(id),
62
- role TEXT NOT NULL CHECK(role IN ('user','admin')),
63
- expires_at INTEGER NOT NULL,
64
- created_at INTEGER NOT NULL
65
- );
66
- CREATE INDEX IF NOT EXISTS sessions_by_user ON sessions(user_id, expires_at);
67
- CREATE TABLE IF NOT EXISTS audit_log (
68
- id INTEGER PRIMARY KEY AUTOINCREMENT,
69
- actor TEXT NOT NULL,
70
- action TEXT NOT NULL,
71
- target_id TEXT,
72
- details_json TEXT NOT NULL,
73
- created_at INTEGER NOT NULL
74
- );
75
- CREATE TABLE IF NOT EXISTS relay_meta (
76
- key TEXT PRIMARY KEY,
77
- value TEXT NOT NULL
78
- );
79
- `);
80
- this.secureDatabaseFiles();
81
- }
82
-
83
- close() { this.db.close(); }
84
- tokenHash(token) { return crypto.createHash('sha256').update(token).digest('hex'); }
85
-
86
- secureDatabaseFiles() {
87
- if(process.platform==='win32')return;
88
- for(const suffix of ['', '-wal', '-shm'])try{fs.chmodSync(`${this.filePath}${suffix}`,0o600)}catch{}
89
- }
90
-
91
- syncAdminToken(token) {
92
- const current=this.tokenHash(token);
93
- const previous=this.db.prepare("SELECT value FROM relay_meta WHERE key='admin_token_hash'").get()?.value;
94
- if(previous!==current)this.db.prepare("DELETE FROM sessions WHERE role='admin'").run();
95
- this.db.prepare("INSERT INTO relay_meta(key,value) VALUES('admin_token_hash',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(current);
96
- }
97
-
98
- async createUser({ username, displayName, password, plan = 'standard', maxDevices = 2, expiresAt = null }, { actor = 'admin', action = 'user.created' } = {}) {
99
- username = safeString(username, '用户名', 64).toLowerCase();
100
- if (!/^[a-z0-9][a-z0-9._-]{2,63}$/.test(username)) throw new Error('用户名须为 3–64 位,仅可用字母、数字、点、连字符和下划线。');
101
- displayName = safeString(displayName || username, '显示名称', 120);
102
- password = validPassword(password);
103
- plan = safeString(plan, '套餐', 40);
104
- maxDevices = safeCount(maxDevices, { min:1, max:50 });
105
- if(expiresAt!==null&&(!Number.isSafeInteger(expiresAt)||expiresAt<Date.now()))throw new Error('账号到期时间必须是未来时间。');
106
- const salt = crypto.randomBytes(16);
107
- const passwordHash = await scrypt(password, salt, 64);
108
- const id = crypto.randomUUID();
109
- const createdAt = Date.now();
110
- try {
111
- this.db.prepare(`
112
- INSERT INTO users(id,username,display_name,password_salt,password_hash,status,plan,max_devices,expires_at,created_at)
113
- VALUES(?,?,?,?,?,'active',?,?,?,?)
114
- `).run(id,username,displayName,salt.toString('hex'),passwordHash.toString('hex'),plan,maxDevices,expiresAt,createdAt);
115
- } catch (error) {
116
- if (error.errcode === 2067) throw Object.assign(new Error('此用户名已被使用,请换一个。'), { status:409 });
117
- throw error;
118
- }
119
- this.audit(actor, action, id, { username, plan, maxDevices });
120
- return this.getUser(id);
121
- }
122
-
123
- registerUser({ username, displayName, password }) {
124
- return this.createUser(
125
- { username, displayName, password, plan:'free', maxDevices:1, expiresAt:null },
126
- { actor:'registration', action:'user.registered' }
127
- );
128
- }
129
-
130
- getUser(id) {
131
- const row = this.db.prepare('SELECT id,username,display_name AS displayName,status,plan,max_devices AS maxDevices,expires_at AS expiresAt,created_at AS createdAt FROM users WHERE id=?').get(id);
132
- return row || null;
133
- }
134
-
135
- listUsers({ search = '', status = 'all', page = 1, pageSize = 25 } = {}) {
136
- const filters = [];
137
- const values = [];
138
- const normalizedSearch = String(search).trim();
139
- if (normalizedSearch) {
140
- const escaped = normalizedSearch.replace(/[\\%_]/g, '\\$&');
141
- filters.push("(u.username LIKE ? ESCAPE '\\' OR u.display_name LIKE ? ESCAPE '\\')");
142
- values.push(`%${escaped}%`, `%${escaped}%`);
143
- }
144
- if (status === 'active') {
145
- filters.push("u.status='active' AND (u.expires_at IS NULL OR u.expires_at>?)");
146
- values.push(Date.now());
147
- } else if (status === 'suspended') {
148
- filters.push("u.status='suspended'");
149
- } else if (status === 'expired') {
150
- filters.push("u.status='active' AND u.expires_at IS NOT NULL AND u.expires_at<=?");
151
- values.push(Date.now());
152
- }
153
- const where = filters.length ? `WHERE ${filters.join(' AND ')}` : '';
154
- const total = this.db.prepare(`SELECT COUNT(*) AS count FROM users u ${where}`).get(...values).count;
155
- const lastPage = Math.max(1, Math.ceil(total / pageSize));
156
- const currentPage = Math.min(page, lastPage);
157
- const users = this.db.prepare(`
158
- SELECT u.id,u.username,u.display_name AS displayName,u.status,u.plan,
159
- u.max_devices AS maxDevices,u.expires_at AS expiresAt,u.created_at AS createdAt,
160
- COUNT(d.id) AS deviceCount
161
- FROM users u LEFT JOIN devices d ON d.user_id=u.id AND d.status='active'
162
- ${where}
163
- GROUP BY u.id ORDER BY u.created_at DESC LIMIT ? OFFSET ?
164
- `).all(...values, pageSize, (currentPage - 1) * pageSize);
165
- return { users, total, page:currentPage, pageSize };
166
- }
167
-
168
- listDevicesForUsers(userIds) {
169
- if (!userIds.length) return [];
170
- const placeholders = userIds.map(() => '?').join(',');
171
- return this.db.prepare(`
172
- SELECT id,userId,name,status,createdAt,lastSeen,username,displayName
173
- FROM (
174
- SELECT d.id,d.user_id AS userId,d.name,d.status,d.created_at AS createdAt,d.last_seen AS lastSeen,
175
- u.username,u.display_name AS displayName,
176
- ROW_NUMBER() OVER(
177
- PARTITION BY d.user_id
178
- ORDER BY CASE WHEN d.status='active' THEN 0 ELSE 1 END,d.created_at DESC
179
- ) AS rowNumber
180
- FROM devices d JOIN users u ON u.id=d.user_id
181
- WHERE d.user_id IN (${placeholders})
182
- )
183
- WHERE rowNumber<=100
184
- ORDER BY createdAt DESC
185
- `).all(...userIds);
186
- }
187
-
188
- async authenticateUser(username, password) {
189
- const normalized = typeof username === 'string' ? username.trim().toLowerCase() : '';
190
- const row = this.db.prepare('SELECT * FROM users WHERE username=?').get(normalized);
191
- const salt = row ? Buffer.from(row.password_salt, 'hex') : Buffer.alloc(16);
192
- const expected = row ? Buffer.from(row.password_hash, 'hex') : Buffer.alloc(64);
193
- const actual = await scrypt(typeof password === 'string' && password.length<=256 ? password : '', salt, 64);
194
- const valid = actual.length === expected.length && crypto.timingSafeEqual(actual, expected);
195
- if (!row || !valid) return null;
196
- const current=this.db.prepare('SELECT password_hash,status,expires_at FROM users WHERE id=?').get(row.id);
197
- if(!current||current.password_hash!==row.password_hash||current.status!=='active'||current.expires_at&&current.expires_at<=Date.now())return null;
198
- return this.getUser(row.id);
199
- }
200
-
201
- async resetPassword(userId, password) {
202
- password = validPassword(password);
203
- const salt = crypto.randomBytes(16);
204
- const passwordHash = await scrypt(password, salt, 64);
205
- const result = this.db.prepare('UPDATE users SET password_salt=?,password_hash=? WHERE id=?')
206
- .run(salt.toString('hex'),passwordHash.toString('hex'),userId);
207
- if (!result.changes) return false;
208
- this.db.prepare('DELETE FROM sessions WHERE user_id=?').run(userId);
209
- this.audit('admin', 'user.password_reset', userId, {});
210
- return true;
211
- }
212
-
213
- updateUser(userId, changes) {
214
- const allowed = new Set(['status','displayName','plan','maxDevices','expiresAt']);
215
- if (!changes || typeof changes !== 'object' || Object.keys(changes).some(key => !allowed.has(key))) throw new Error('用户更新字段无效。');
216
- const user = this.getUser(userId);
217
- if (!user) return null;
218
- const next = {
219
- status: changes.status === undefined ? user.status : changes.status,
220
- displayName: changes.displayName === undefined ? user.displayName : safeString(changes.displayName, '显示名称', 120),
221
- plan: changes.plan === undefined ? user.plan : safeString(changes.plan, '套餐', 40),
222
- maxDevices: changes.maxDevices === undefined ? user.maxDevices : safeCount(changes.maxDevices, { min:1, max:50 }),
223
- expiresAt: changes.expiresAt === undefined ? user.expiresAt : changes.expiresAt
224
- };
225
- if (!['active','suspended'].includes(next.status)) throw new Error('用户状态无效。');
226
- if (next.expiresAt !== null && (!Number.isSafeInteger(next.expiresAt) || next.expiresAt <= 0)) throw new Error('到期时间无效。');
227
- const activeDeviceCount=this.db.prepare("SELECT COUNT(*) AS count FROM devices WHERE user_id=? AND status='active'").get(userId).count;
228
- if(next.maxDevices<activeDeviceCount)throw new Error(`当前已有 ${activeDeviceCount} 台有效设备,不能把额度调得更低。`);
229
- this.db.prepare('UPDATE users SET status=?,display_name=?,plan=?,max_devices=?,expires_at=? WHERE id=?')
230
- .run(next.status,next.displayName,next.plan,next.maxDevices,next.expiresAt,userId);
231
- if (next.status === 'suspended'||next.expiresAt&&next.expiresAt<=Date.now()) this.db.prepare('DELETE FROM sessions WHERE user_id=?').run(userId);
232
- this.audit('admin', `user.${next.status!==user.status?next.status:'updated'}`, userId, { plan:next.plan,maxDevices:next.maxDevices,expiresAt:next.expiresAt });
233
- return this.getUser(userId);
234
- }
235
-
236
- listDevices(userId = null) {
237
- const where = userId === null ? '' : 'WHERE d.user_id=?';
238
- return this.db.prepare(`
239
- SELECT d.id,d.user_id AS userId,d.name,d.status,d.created_at AS createdAt,d.last_seen AS lastSeen,
240
- u.username,u.display_name AS displayName
241
- FROM devices d JOIN users u ON u.id=d.user_id ${where}
242
- ORDER BY d.created_at DESC LIMIT 1000
243
- `).all(...(userId === null ? [] : [userId]));
244
- }
245
-
246
- issueDevice(userId, name, actor = 'admin') {
247
- const user = this.getUser(userId);
248
- if (!user || user.status !== 'active'||user.expiresAt&&user.expiresAt<=Date.now()) throw new Error('找不到有效用户。');
249
- const activeCount = this.db.prepare("SELECT COUNT(*) AS count FROM devices WHERE user_id=? AND status='active'").get(userId).count;
250
- if (activeCount >= user.maxDevices) throw new Error('此用户已达到设备上限。');
251
- const id = crypto.randomUUID();
252
- const token = crypto.randomBytes(32).toString('base64url');
253
- const displayName = safeString(name || `电脑 ${activeCount + 1}`, '设备名称', 120);
254
- this.db.prepare('INSERT INTO devices(id,user_id,name,token_hash,status,created_at) VALUES(?,?,?,?,?,?)')
255
- .run(id,userId,displayName,this.tokenHash(token),'active',Date.now());
256
- this.audit(actor, 'device.issued', id, { userId, name:displayName });
257
- return { device:this.listDevices(userId).find(device => device.id === id), agentToken:token };
258
- }
259
-
260
- authenticateDevice(token, deviceId = null) {
261
- if (typeof token !== 'string' || token.length < 32) return null;
262
- const tokenHash = this.tokenHash(token);
263
- const row = this.db.prepare(`
264
- SELECT d.id,d.user_id AS userId,d.name,d.status,d.token_hash AS tokenHash,
265
- u.status AS userStatus,u.expires_at AS userExpiresAt
266
- FROM devices d JOIN users u ON u.id=d.user_id WHERE d.token_hash=?
267
- `).get(tokenHash);
268
- if (!row || row.status !== 'active' || row.userStatus !== 'active'
269
- || (row.userExpiresAt && row.userExpiresAt <= Date.now())
270
- || (deviceId && row.id !== deviceId)) return null;
271
- this.db.prepare('UPDATE devices SET last_seen=? WHERE id=?').run(Date.now(),row.id);
272
- return { id:row.id,userId:row.userId,name:row.name };
273
- }
274
-
275
- setDeviceStatus(deviceId, status, name = undefined, actor = 'admin') {
276
- if (!['active','revoked'].includes(status)) throw new Error('设备状态无效。');
277
- const device = this.db.prepare('SELECT * FROM devices WHERE id=?').get(deviceId);
278
- if (!device) return null;
279
- if(device.status==='revoked'&&status==='active')throw new Error('已撤销的设备凭证不能恢复,请签发新的设备凭证。');
280
- const nextName = name === undefined ? device.name : safeString(name,'设备名称',120);
281
- this.db.exec('BEGIN IMMEDIATE');
282
- try {
283
- this.db.prepare('UPDATE devices SET status=?,name=? WHERE id=?').run(status,nextName,deviceId);
284
- this.secureDatabaseFiles();
285
- this.db.exec('COMMIT');
286
- } catch(error) {
287
- try{this.db.exec('ROLLBACK')}catch{}
288
- throw error;
289
- }
290
- this.audit(actor,`device.${status}`,deviceId,{userId:device.user_id});
291
- return this.listDevices(device.user_id).find(item=>item.id===deviceId);
292
- }
293
-
294
- setSession(rawToken, role, userId, expiresAt) {
295
- const token = rawToken || crypto.randomBytes(32).toString('base64url');
296
- const expires = expiresAt || Date.now() + 12 * 60 * 60 * 1000;
297
- this.db.prepare('INSERT INTO sessions(token_hash,user_id,role,expires_at,created_at) VALUES(?,?,?,?,?)')
298
- .run(this.tokenHash(token),userId,role,expires,Date.now());
299
- return { token,expiresAt:expires };
300
- }
301
-
302
- getSession(rawToken, role) {
303
- if (typeof rawToken !== 'string' || rawToken.length < 32) return null;
304
- const tokenHash = this.tokenHash(rawToken);
305
- const session = this.db.prepare('SELECT * FROM sessions WHERE token_hash=? AND role=?').get(tokenHash,role);
306
- if (!session || session.expires_at <= Date.now()) {
307
- if (session) this.db.prepare('DELETE FROM sessions WHERE token_hash=?').run(tokenHash);
308
- return null;
309
- }
310
- if (role === 'user') {
311
- const user = this.getUser(session.user_id);
312
- if (!user || user.status !== 'active' || (user.expiresAt && user.expiresAt <= Date.now())) {
313
- this.db.prepare('DELETE FROM sessions WHERE token_hash=?').run(tokenHash);
314
- return null;
315
- }
316
- return { tokenHash,user };
317
- }
318
- return { tokenHash,role };
319
- }
320
-
321
- hasSession(tokenHash,userId) {
322
- const row=this.db.prepare('SELECT expires_at AS expiresAt FROM sessions WHERE token_hash=? AND user_id=? AND role=\'user\'').get(tokenHash,userId);
323
- if(!row)return false;
324
- if(row.expiresAt<=Date.now()){this.db.prepare('DELETE FROM sessions WHERE token_hash=?').run(tokenHash);return false}
325
- return true;
326
- }
327
-
328
- cleanup() {
329
- this.db.prepare('DELETE FROM sessions WHERE expires_at<=?').run(Date.now());
330
- }
331
-
332
- revokeSession(rawToken) {
333
- if (typeof rawToken === 'string') this.db.prepare('DELETE FROM sessions WHERE token_hash=?').run(this.tokenHash(rawToken));
334
- }
335
-
336
- devicesForUser(userId) {
337
- return this.listDevices(userId).filter(device => device.status === 'active');
338
- }
339
-
340
- touchDevice(deviceId) {
341
- this.db.prepare('UPDATE devices SET last_seen=? WHERE id=? AND status=\'active\'').run(Date.now(),deviceId);
342
- }
343
-
344
- audit(actor, action, targetId, details) {
345
- this.db.prepare('INSERT INTO audit_log(actor,action,target_id,details_json,created_at) VALUES(?,?,?,?,?)')
346
- .run(actor,action,targetId || null,JSON.stringify(details || {}),Date.now());
347
- }
348
-
349
- listAudit(limit = 100) {
350
- return this.db.prepare('SELECT id,actor,action,target_id AS targetId,details_json AS details,created_at AS createdAt FROM audit_log ORDER BY id DESC LIMIT ?')
351
- .all(safeCount(limit,{min:1,max:500})).map(row=>({...row,details:JSON.parse(row.details)}));
352
- }
353
-
354
- overview(onlineDeviceIds) {
355
- const counts = this.db.prepare(`
356
- SELECT
357
- (SELECT COUNT(*) FROM users) AS users,
358
- (SELECT COUNT(*) FROM users WHERE status='active' AND (expires_at IS NULL OR expires_at > unixepoch('subsec')*1000)) AS activeUsers,
359
- (SELECT COUNT(*) FROM users WHERE status='suspended') AS suspendedUsers,
360
- (SELECT COUNT(*) FROM users WHERE status='active' AND expires_at IS NOT NULL AND expires_at <= unixepoch('subsec')*1000) AS expiredUsers,
361
- (SELECT COUNT(*) FROM devices) AS devices,
362
- (SELECT COUNT(*) FROM devices d JOIN users u ON u.id=d.user_id
363
- WHERE d.status='active' AND u.status='active'
364
- AND (u.expires_at IS NULL OR u.expires_at > unixepoch('subsec')*1000)) AS activeDevices
365
- `).get();
366
- return {...counts,onlineDevices:onlineDeviceIds.length,uptimeSeconds:Math.floor(process.uptime())};
367
- }
368
- }