@ganglion/xacpx-relay 0.1.0
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/README.md +10 -0
- package/dist/auth.d.ts +6 -0
- package/dist/cli.d.ts +24 -0
- package/dist/cli.js +1168 -0
- package/dist/db.d.ts +9 -0
- package/dist/gateway/instance-gateway.d.ts +30 -0
- package/dist/gateway/web-gateway.d.ts +11 -0
- package/dist/http/app.d.ts +33 -0
- package/dist/http/client-ip.d.ts +2 -0
- package/dist/maintenance.d.ts +22 -0
- package/dist/relay-web/assets/BrandLogo.vue_vue_type_script_setup_true_lang-CSPZDDtK.js +1 -0
- package/dist/relay-web/assets/DashboardView-AMivJ-D8.css +1 -0
- package/dist/relay-web/assets/DashboardView-Bhx2FJSv.js +256 -0
- package/dist/relay-web/assets/LoginView-CYhQnBi-.js +1 -0
- package/dist/relay-web/assets/SettingsView-hmSvIsKD.js +6 -0
- package/dist/relay-web/assets/index-DC1zO5jV.css +1 -0
- package/dist/relay-web/assets/index-DE2bTqBl.js +80 -0
- package/dist/relay-web/assets/inter-cyrillic-400-normal-HOLc17fK.woff +0 -0
- package/dist/relay-web/assets/inter-cyrillic-400-normal-obahsSVq.woff2 +0 -0
- package/dist/relay-web/assets/inter-cyrillic-500-normal-BasfLYem.woff2 +0 -0
- package/dist/relay-web/assets/inter-cyrillic-500-normal-CxZf_p3X.woff +0 -0
- package/dist/relay-web/assets/inter-cyrillic-600-normal-4D_pXhcN.woff +0 -0
- package/dist/relay-web/assets/inter-cyrillic-600-normal-CWCymEST.woff2 +0 -0
- package/dist/relay-web/assets/inter-cyrillic-700-normal-CjBOestx.woff2 +0 -0
- package/dist/relay-web/assets/inter-cyrillic-700-normal-DrXBdSj3.woff +0 -0
- package/dist/relay-web/assets/inter-cyrillic-ext-400-normal-BQZuk6qB.woff2 +0 -0
- package/dist/relay-web/assets/inter-cyrillic-ext-400-normal-DQukG94-.woff +0 -0
- package/dist/relay-web/assets/inter-cyrillic-ext-500-normal-B0yAr1jD.woff2 +0 -0
- package/dist/relay-web/assets/inter-cyrillic-ext-500-normal-BmqWE9Dz.woff +0 -0
- package/dist/relay-web/assets/inter-cyrillic-ext-600-normal-Bcila6Z-.woff +0 -0
- package/dist/relay-web/assets/inter-cyrillic-ext-600-normal-Dfes3d0z.woff2 +0 -0
- package/dist/relay-web/assets/inter-cyrillic-ext-700-normal-BjwYoWNd.woff2 +0 -0
- package/dist/relay-web/assets/inter-cyrillic-ext-700-normal-LO58E6JB.woff +0 -0
- package/dist/relay-web/assets/inter-greek-400-normal-B4URO6DV.woff2 +0 -0
- package/dist/relay-web/assets/inter-greek-400-normal-q2sYcFCs.woff +0 -0
- package/dist/relay-web/assets/inter-greek-500-normal-BIZE56-Y.woff2 +0 -0
- package/dist/relay-web/assets/inter-greek-500-normal-Xzm54t5V.woff +0 -0
- package/dist/relay-web/assets/inter-greek-600-normal-BZpKdvQh.woff +0 -0
- package/dist/relay-web/assets/inter-greek-600-normal-plRanbMR.woff2 +0 -0
- package/dist/relay-web/assets/inter-greek-700-normal-BUv2fZ6O.woff +0 -0
- package/dist/relay-web/assets/inter-greek-700-normal-C3JjAnD8.woff2 +0 -0
- package/dist/relay-web/assets/inter-greek-ext-400-normal-DGGRlc-M.woff2 +0 -0
- package/dist/relay-web/assets/inter-greek-ext-400-normal-KugGGMne.woff +0 -0
- package/dist/relay-web/assets/inter-greek-ext-500-normal-2j5mBUwD.woff +0 -0
- package/dist/relay-web/assets/inter-greek-ext-500-normal-C4iEst2y.woff2 +0 -0
- package/dist/relay-web/assets/inter-greek-ext-600-normal-B8X0CLgF.woff +0 -0
- package/dist/relay-web/assets/inter-greek-ext-600-normal-DRtmH8MT.woff2 +0 -0
- package/dist/relay-web/assets/inter-greek-ext-700-normal-BoQ6DsYi.woff +0 -0
- package/dist/relay-web/assets/inter-greek-ext-700-normal-qfdV9bQt.woff2 +0 -0
- package/dist/relay-web/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
- package/dist/relay-web/assets/inter-latin-400-normal-CyCys3Eg.woff +0 -0
- package/dist/relay-web/assets/inter-latin-500-normal-BL9OpVg8.woff +0 -0
- package/dist/relay-web/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
- package/dist/relay-web/assets/inter-latin-600-normal-CiBQ2DWP.woff +0 -0
- package/dist/relay-web/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
- package/dist/relay-web/assets/inter-latin-700-normal-BLAVimhd.woff +0 -0
- package/dist/relay-web/assets/inter-latin-700-normal-Yt3aPRUw.woff2 +0 -0
- package/dist/relay-web/assets/inter-latin-ext-400-normal-77YHD8bZ.woff +0 -0
- package/dist/relay-web/assets/inter-latin-ext-400-normal-C1nco2VV.woff2 +0 -0
- package/dist/relay-web/assets/inter-latin-ext-500-normal-BxGbmqWO.woff +0 -0
- package/dist/relay-web/assets/inter-latin-ext-500-normal-CV4jyFjo.woff2 +0 -0
- package/dist/relay-web/assets/inter-latin-ext-600-normal-CIVaiw4L.woff +0 -0
- package/dist/relay-web/assets/inter-latin-ext-600-normal-D2bJ5OIk.woff2 +0 -0
- package/dist/relay-web/assets/inter-latin-ext-700-normal-Ca8adRJv.woff2 +0 -0
- package/dist/relay-web/assets/inter-latin-ext-700-normal-TidjK2hL.woff +0 -0
- package/dist/relay-web/assets/inter-vietnamese-400-normal-Bbgyi5SW.woff +0 -0
- package/dist/relay-web/assets/inter-vietnamese-400-normal-DMkecbls.woff2 +0 -0
- package/dist/relay-web/assets/inter-vietnamese-500-normal-DOriooB6.woff2 +0 -0
- package/dist/relay-web/assets/inter-vietnamese-500-normal-mJboJaSs.woff +0 -0
- package/dist/relay-web/assets/inter-vietnamese-600-normal-BuLX-rYi.woff +0 -0
- package/dist/relay-web/assets/inter-vietnamese-600-normal-Cc8MFFhd.woff2 +0 -0
- package/dist/relay-web/assets/inter-vietnamese-700-normal-BZaoP0fm.woff +0 -0
- package/dist/relay-web/assets/inter-vietnamese-700-normal-DlLaEgI2.woff2 +0 -0
- package/dist/relay-web/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 +0 -0
- package/dist/relay-web/assets/jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff +0 -0
- package/dist/relay-web/assets/jetbrains-mono-cyrillic-500-normal-DJqRU3vO.woff +0 -0
- package/dist/relay-web/assets/jetbrains-mono-cyrillic-500-normal-DmUKJPL_.woff2 +0 -0
- package/dist/relay-web/assets/jetbrains-mono-greek-400-normal-B9oWc5Lo.woff +0 -0
- package/dist/relay-web/assets/jetbrains-mono-greek-400-normal-C190GLew.woff2 +0 -0
- package/dist/relay-web/assets/jetbrains-mono-greek-500-normal-D7SFKleX.woff +0 -0
- package/dist/relay-web/assets/jetbrains-mono-greek-500-normal-JpySY46c.woff2 +0 -0
- package/dist/relay-web/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
- package/dist/relay-web/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
- package/dist/relay-web/assets/jetbrains-mono-latin-500-normal-BWZEU5yA.woff2 +0 -0
- package/dist/relay-web/assets/jetbrains-mono-latin-500-normal-CJOVTJB7.woff +0 -0
- package/dist/relay-web/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
- package/dist/relay-web/assets/jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff +0 -0
- package/dist/relay-web/assets/jetbrains-mono-latin-ext-500-normal-Cut-4mMH.woff2 +0 -0
- package/dist/relay-web/assets/jetbrains-mono-latin-ext-500-normal-ckzbgY84.woff +0 -0
- package/dist/relay-web/assets/jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff +0 -0
- package/dist/relay-web/assets/jetbrains-mono-vietnamese-500-normal-DNRqzVM1.woff +0 -0
- package/dist/relay-web/assets/theme-CaepIjgl.js +1 -0
- package/dist/relay-web/index.html +28 -0
- package/dist/server.d.ts +42 -0
- package/dist/stores/accounts.d.ts +74 -0
- package/dist/stores/instances.d.ts +42 -0
- package/dist/stores/messages.d.ts +30 -0
- package/package.json +24 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1168 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
3
|
+
|
|
4
|
+
// packages/relay/src/cli.ts
|
|
5
|
+
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
6
|
+
import { existsSync } from "node:fs";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { dirname as dirname2, join, resolve } from "node:path";
|
|
9
|
+
|
|
10
|
+
// packages/relay/src/server.ts
|
|
11
|
+
import { serve } from "@hono/node-server";
|
|
12
|
+
import { WebSocketServer } from "ws";
|
|
13
|
+
import {
|
|
14
|
+
MSG as MSG3
|
|
15
|
+
} from "@ganglion/xacpx-relay-protocol";
|
|
16
|
+
|
|
17
|
+
// packages/relay/src/db.ts
|
|
18
|
+
import { mkdirSync } from "node:fs";
|
|
19
|
+
import { dirname } from "node:path";
|
|
20
|
+
async function createSqlDriver(path) {
|
|
21
|
+
if (path !== ":memory:") {
|
|
22
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
23
|
+
}
|
|
24
|
+
if (typeof Bun !== "undefined") {
|
|
25
|
+
const { Database } = await import("bun:sqlite");
|
|
26
|
+
const db2 = new Database(path);
|
|
27
|
+
return {
|
|
28
|
+
exec: (sql) => db2.exec(sql),
|
|
29
|
+
run: (sql, params = []) => {
|
|
30
|
+
db2.query(sql).run(...params);
|
|
31
|
+
},
|
|
32
|
+
get: (sql, params = []) => db2.query(sql).get(...params) ?? undefined,
|
|
33
|
+
all: (sql, params = []) => db2.query(sql).all(...params),
|
|
34
|
+
close: () => db2.close()
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
const { DatabaseSync } = await import("node:sqlite");
|
|
38
|
+
const db = new DatabaseSync(path, { enableForeignKeyConstraints: false });
|
|
39
|
+
return {
|
|
40
|
+
exec: (sql) => db.exec(sql),
|
|
41
|
+
run: (sql, params = []) => {
|
|
42
|
+
db.prepare(sql).run(...params);
|
|
43
|
+
},
|
|
44
|
+
get: (sql, params = []) => db.prepare(sql).get(...params) ?? undefined,
|
|
45
|
+
all: (sql, params = []) => db.prepare(sql).all(...params),
|
|
46
|
+
close: () => db.close()
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function initSchema(db) {
|
|
50
|
+
db.exec(`
|
|
51
|
+
CREATE TABLE IF NOT EXISTS accounts (
|
|
52
|
+
id TEXT PRIMARY KEY,
|
|
53
|
+
username TEXT NOT NULL UNIQUE,
|
|
54
|
+
created_at TEXT NOT NULL
|
|
55
|
+
);
|
|
56
|
+
`);
|
|
57
|
+
db.exec(`
|
|
58
|
+
CREATE TABLE IF NOT EXISTS login_tokens (
|
|
59
|
+
id TEXT PRIMARY KEY,
|
|
60
|
+
token_hash TEXT NOT NULL UNIQUE,
|
|
61
|
+
account_id TEXT NOT NULL REFERENCES accounts(id),
|
|
62
|
+
label TEXT,
|
|
63
|
+
created_at TEXT NOT NULL,
|
|
64
|
+
last_used_at TEXT
|
|
65
|
+
);
|
|
66
|
+
`);
|
|
67
|
+
db.exec(`
|
|
68
|
+
CREATE TABLE IF NOT EXISTS web_sessions (
|
|
69
|
+
token_hash TEXT PRIMARY KEY,
|
|
70
|
+
account_id TEXT NOT NULL REFERENCES accounts(id),
|
|
71
|
+
login_token_id TEXT REFERENCES login_tokens(id),
|
|
72
|
+
expires_at TEXT NOT NULL
|
|
73
|
+
);
|
|
74
|
+
`);
|
|
75
|
+
db.exec(`
|
|
76
|
+
CREATE INDEX IF NOT EXISTS idx_web_sessions_login_token ON web_sessions(login_token_id);
|
|
77
|
+
`);
|
|
78
|
+
db.exec(`
|
|
79
|
+
CREATE TABLE IF NOT EXISTS pairing_tokens (
|
|
80
|
+
token_hash TEXT PRIMARY KEY,
|
|
81
|
+
account_id TEXT NOT NULL REFERENCES accounts(id),
|
|
82
|
+
name TEXT,
|
|
83
|
+
expires_at TEXT NOT NULL,
|
|
84
|
+
used_at TEXT
|
|
85
|
+
);
|
|
86
|
+
CREATE TABLE IF NOT EXISTS instances (
|
|
87
|
+
id TEXT PRIMARY KEY,
|
|
88
|
+
account_id TEXT NOT NULL REFERENCES accounts(id),
|
|
89
|
+
name TEXT NOT NULL,
|
|
90
|
+
credential_hash TEXT NOT NULL,
|
|
91
|
+
core_version TEXT,
|
|
92
|
+
last_seen_at TEXT,
|
|
93
|
+
created_at TEXT NOT NULL
|
|
94
|
+
);
|
|
95
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
96
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
97
|
+
instance_id TEXT NOT NULL REFERENCES instances(id),
|
|
98
|
+
session_alias TEXT NOT NULL,
|
|
99
|
+
direction TEXT NOT NULL CHECK (direction IN ('in','out')),
|
|
100
|
+
text TEXT NOT NULL,
|
|
101
|
+
created_at TEXT NOT NULL,
|
|
102
|
+
structured TEXT
|
|
103
|
+
);
|
|
104
|
+
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages (instance_id, session_alias, id);
|
|
105
|
+
`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// packages/relay/src/stores/accounts.ts
|
|
109
|
+
import { randomUUID } from "node:crypto";
|
|
110
|
+
|
|
111
|
+
// packages/relay/src/auth.ts
|
|
112
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
113
|
+
function generateToken() {
|
|
114
|
+
return randomBytes(32).toString("base64url");
|
|
115
|
+
}
|
|
116
|
+
function hashToken(token) {
|
|
117
|
+
return createHash("sha256").update(token).digest("hex");
|
|
118
|
+
}
|
|
119
|
+
function hashEquals(a, b) {
|
|
120
|
+
if (a.length !== b.length)
|
|
121
|
+
return false;
|
|
122
|
+
return timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// packages/relay/src/stores/accounts.ts
|
|
126
|
+
class AccountStore {
|
|
127
|
+
db;
|
|
128
|
+
now;
|
|
129
|
+
constructor(db, options = {}) {
|
|
130
|
+
this.db = db;
|
|
131
|
+
this.now = options.now ?? (() => new Date);
|
|
132
|
+
}
|
|
133
|
+
createAccount(username) {
|
|
134
|
+
const id = randomUUID();
|
|
135
|
+
const createdAt = this.now().toISOString();
|
|
136
|
+
this.db.run("INSERT INTO accounts (id, username, created_at) VALUES (?, ?, ?)", [id, username, createdAt]);
|
|
137
|
+
return { id, username, createdAt };
|
|
138
|
+
}
|
|
139
|
+
findByUsername(username) {
|
|
140
|
+
const row = this.db.get("SELECT id, username, created_at FROM accounts WHERE username = ?", [username]);
|
|
141
|
+
return row ? { id: row.id, username: row.username, createdAt: row.created_at } : null;
|
|
142
|
+
}
|
|
143
|
+
findById(id) {
|
|
144
|
+
const row = this.db.get("SELECT id, username, created_at FROM accounts WHERE id = ?", [id]);
|
|
145
|
+
return row ? { id: row.id, username: row.username, createdAt: row.created_at } : null;
|
|
146
|
+
}
|
|
147
|
+
listAccounts() {
|
|
148
|
+
return this.db.all(`SELECT a.id, a.username, a.created_at,
|
|
149
|
+
(SELECT COUNT(*) FROM login_tokens lt WHERE lt.account_id = a.id) AS token_count,
|
|
150
|
+
(SELECT COUNT(*) FROM instances i WHERE i.account_id = a.id) AS instance_count
|
|
151
|
+
FROM accounts a
|
|
152
|
+
ORDER BY a.created_at`).map((row) => ({
|
|
153
|
+
id: row.id,
|
|
154
|
+
username: row.username,
|
|
155
|
+
createdAt: row.created_at,
|
|
156
|
+
tokenCount: row.token_count,
|
|
157
|
+
instanceCount: row.instance_count
|
|
158
|
+
}));
|
|
159
|
+
}
|
|
160
|
+
countInstances(accountId) {
|
|
161
|
+
const row = this.db.get("SELECT COUNT(*) AS n FROM instances WHERE account_id = ?", [accountId]);
|
|
162
|
+
return row?.n ?? 0;
|
|
163
|
+
}
|
|
164
|
+
createLoginToken(accountId, label) {
|
|
165
|
+
const id = randomUUID();
|
|
166
|
+
const token = generateToken();
|
|
167
|
+
const createdAt = this.now().toISOString();
|
|
168
|
+
this.db.run("INSERT INTO login_tokens (id, token_hash, account_id, label, created_at, last_used_at) VALUES (?, ?, ?, ?, ?, NULL)", [id, hashToken(token), accountId, label ?? null, createdAt]);
|
|
169
|
+
return { id, token };
|
|
170
|
+
}
|
|
171
|
+
_resolveLoginToken(token) {
|
|
172
|
+
const row = this.db.get("SELECT id, account_id FROM login_tokens WHERE token_hash = ?", [hashToken(token)]);
|
|
173
|
+
if (!row)
|
|
174
|
+
return null;
|
|
175
|
+
const account = this.findById(row.account_id);
|
|
176
|
+
if (!account)
|
|
177
|
+
return null;
|
|
178
|
+
this.db.run("UPDATE login_tokens SET last_used_at = ? WHERE id = ?", [this.now().toISOString(), row.id]);
|
|
179
|
+
return { account, loginTokenId: row.id };
|
|
180
|
+
}
|
|
181
|
+
findAccountByLoginToken(token) {
|
|
182
|
+
return this._resolveLoginToken(token)?.account ?? null;
|
|
183
|
+
}
|
|
184
|
+
resolveLoginToken(token) {
|
|
185
|
+
return this._resolveLoginToken(token);
|
|
186
|
+
}
|
|
187
|
+
listLoginTokens(accountId) {
|
|
188
|
+
return this.db.all("SELECT id, label, created_at, last_used_at FROM login_tokens WHERE account_id = ? ORDER BY created_at", [accountId]).map((row) => ({
|
|
189
|
+
id: row.id,
|
|
190
|
+
label: row.label,
|
|
191
|
+
createdAt: row.created_at,
|
|
192
|
+
lastUsedAt: row.last_used_at
|
|
193
|
+
}));
|
|
194
|
+
}
|
|
195
|
+
listTokens() {
|
|
196
|
+
return this.db.all(`SELECT lt.id, lt.label, lt.created_at, lt.account_id,
|
|
197
|
+
(SELECT COUNT(*) FROM instances i WHERE i.account_id = lt.account_id) AS instance_count
|
|
198
|
+
FROM login_tokens lt
|
|
199
|
+
ORDER BY lt.created_at`).map((row) => ({
|
|
200
|
+
id: row.id,
|
|
201
|
+
label: row.label,
|
|
202
|
+
createdAt: row.created_at,
|
|
203
|
+
accountId: row.account_id,
|
|
204
|
+
instanceCount: row.instance_count
|
|
205
|
+
}));
|
|
206
|
+
}
|
|
207
|
+
accountIdForToken(valueOrId) {
|
|
208
|
+
const byValue = this._resolveLoginToken(valueOrId);
|
|
209
|
+
if (byValue)
|
|
210
|
+
return byValue.account.id;
|
|
211
|
+
const byId = this.db.get("SELECT account_id FROM login_tokens WHERE id = ?", [valueOrId]);
|
|
212
|
+
if (byId)
|
|
213
|
+
return byId.account_id;
|
|
214
|
+
const byPrefix = this.db.all("SELECT account_id FROM login_tokens WHERE id LIKE ? || '%'", [valueOrId]);
|
|
215
|
+
if (byPrefix.length === 1)
|
|
216
|
+
return byPrefix[0].account_id;
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
revokeLoginToken(tokenId) {
|
|
220
|
+
const existing = this.db.get("SELECT id FROM login_tokens WHERE id = ?", [tokenId]);
|
|
221
|
+
if (!existing)
|
|
222
|
+
return false;
|
|
223
|
+
this.db.run("DELETE FROM web_sessions WHERE login_token_id = ?", [tokenId]);
|
|
224
|
+
this.db.run("DELETE FROM login_tokens WHERE id = ?", [tokenId]);
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
createWebSession(accountId, loginTokenId, ttlMs) {
|
|
228
|
+
const token = generateToken();
|
|
229
|
+
const expiresAt = new Date(this.now().getTime() + ttlMs).toISOString();
|
|
230
|
+
this.db.run("INSERT INTO web_sessions (token_hash, account_id, login_token_id, expires_at) VALUES (?, ?, ?, ?)", [hashToken(token), accountId, loginTokenId, expiresAt]);
|
|
231
|
+
return token;
|
|
232
|
+
}
|
|
233
|
+
getSessionAccount(token) {
|
|
234
|
+
const row = this.db.get("SELECT account_id, expires_at FROM web_sessions WHERE token_hash = ?", [hashToken(token)]);
|
|
235
|
+
if (!row || new Date(row.expires_at).getTime() <= this.now().getTime())
|
|
236
|
+
return null;
|
|
237
|
+
return this.findById(row.account_id);
|
|
238
|
+
}
|
|
239
|
+
deleteWebSession(token) {
|
|
240
|
+
this.db.run("DELETE FROM web_sessions WHERE token_hash = ?", [hashToken(token)]);
|
|
241
|
+
}
|
|
242
|
+
deleteAccountCascade(accountId) {
|
|
243
|
+
this.db.exec("BEGIN");
|
|
244
|
+
try {
|
|
245
|
+
this.db.run("DELETE FROM messages WHERE instance_id IN (SELECT id FROM instances WHERE account_id = ?)", [accountId]);
|
|
246
|
+
this.db.run("DELETE FROM instances WHERE account_id = ?", [accountId]);
|
|
247
|
+
this.db.run("DELETE FROM pairing_tokens WHERE account_id = ?", [accountId]);
|
|
248
|
+
this.db.run("DELETE FROM web_sessions WHERE account_id = ?", [accountId]);
|
|
249
|
+
this.db.run("DELETE FROM login_tokens WHERE account_id = ?", [accountId]);
|
|
250
|
+
this.db.run("DELETE FROM accounts WHERE id = ?", [accountId]);
|
|
251
|
+
this.db.exec("COMMIT");
|
|
252
|
+
} catch (e) {
|
|
253
|
+
this.db.exec("ROLLBACK");
|
|
254
|
+
throw e;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
pruneExpired(now) {
|
|
258
|
+
const iso = now.toISOString();
|
|
259
|
+
const ws = this.db.get("SELECT COUNT(*) AS n FROM web_sessions WHERE expires_at <= ?", [iso]);
|
|
260
|
+
this.db.run("DELETE FROM web_sessions WHERE expires_at <= ?", [iso]);
|
|
261
|
+
return ws?.n ?? 0;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// packages/relay/src/stores/instances.ts
|
|
266
|
+
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
267
|
+
class InstanceStore {
|
|
268
|
+
db;
|
|
269
|
+
now;
|
|
270
|
+
constructor(db, options = {}) {
|
|
271
|
+
this.db = db;
|
|
272
|
+
this.now = options.now ?? (() => new Date);
|
|
273
|
+
}
|
|
274
|
+
issuePairingToken(accountId, name, ttlMs) {
|
|
275
|
+
const token = generateToken();
|
|
276
|
+
const expiresAt = new Date(this.now().getTime() + ttlMs).toISOString();
|
|
277
|
+
this.db.run("INSERT INTO pairing_tokens (token_hash, account_id, name, expires_at) VALUES (?, ?, ?, ?)", [hashToken(token), accountId, name ?? null, expiresAt]);
|
|
278
|
+
return { token, expiresAt };
|
|
279
|
+
}
|
|
280
|
+
registerInstanceForAccount(accountId, name, coreVersion) {
|
|
281
|
+
const instanceId = randomUUID2();
|
|
282
|
+
const credential = generateToken();
|
|
283
|
+
const nowIso = this.now().toISOString();
|
|
284
|
+
const instanceName = name ?? `instance-${instanceId.slice(0, 8)}`;
|
|
285
|
+
this.db.run("INSERT INTO instances (id, account_id, name, credential_hash, core_version, created_at) VALUES (?, ?, ?, ?, ?, ?)", [instanceId, accountId, instanceName, hashToken(credential), coreVersion ?? null, nowIso]);
|
|
286
|
+
return { instanceId, credential, accountId, name: instanceName };
|
|
287
|
+
}
|
|
288
|
+
redeemPairingToken(token, coreVersion) {
|
|
289
|
+
const tokenHash = hashToken(token);
|
|
290
|
+
const row = this.db.get("SELECT account_id, name, expires_at, used_at FROM pairing_tokens WHERE token_hash = ?", [tokenHash]);
|
|
291
|
+
const nowIso = this.now().toISOString();
|
|
292
|
+
if (!row || row.used_at !== null || new Date(row.expires_at).getTime() <= this.now().getTime()) {
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
this.db.run("UPDATE pairing_tokens SET used_at = ? WHERE token_hash = ?", [nowIso, tokenHash]);
|
|
296
|
+
const instanceId = randomUUID2();
|
|
297
|
+
const credential = generateToken();
|
|
298
|
+
const name = row.name ?? `instance-${instanceId.slice(0, 8)}`;
|
|
299
|
+
this.db.run("INSERT INTO instances (id, account_id, name, credential_hash, core_version, created_at) VALUES (?, ?, ?, ?, ?, ?)", [instanceId, row.account_id, name, hashToken(credential), coreVersion ?? null, nowIso]);
|
|
300
|
+
return { instanceId, credential, accountId: row.account_id, name };
|
|
301
|
+
}
|
|
302
|
+
verifyCredential(instanceId, credential) {
|
|
303
|
+
const row = this.db.get("SELECT * FROM instances WHERE id = ?", [instanceId]);
|
|
304
|
+
if (!row || !hashEquals(row.credential_hash, hashToken(credential)))
|
|
305
|
+
return null;
|
|
306
|
+
return toInstanceRow(row);
|
|
307
|
+
}
|
|
308
|
+
touch(instanceId, coreVersion) {
|
|
309
|
+
if (coreVersion !== undefined) {
|
|
310
|
+
this.db.run("UPDATE instances SET last_seen_at = ?, core_version = ? WHERE id = ?", [
|
|
311
|
+
this.now().toISOString(),
|
|
312
|
+
coreVersion,
|
|
313
|
+
instanceId
|
|
314
|
+
]);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
this.db.run("UPDATE instances SET last_seen_at = ? WHERE id = ?", [this.now().toISOString(), instanceId]);
|
|
318
|
+
}
|
|
319
|
+
listByAccount(accountId) {
|
|
320
|
+
return this.db.all("SELECT * FROM instances WHERE account_id = ? ORDER BY created_at", [accountId]).map(toInstanceRow);
|
|
321
|
+
}
|
|
322
|
+
getOwned(instanceId, accountId) {
|
|
323
|
+
const row = this.db.get("SELECT * FROM instances WHERE id = ? AND account_id = ?", [instanceId, accountId]);
|
|
324
|
+
return row ? toInstanceRow(row) : null;
|
|
325
|
+
}
|
|
326
|
+
remove(instanceId, accountId) {
|
|
327
|
+
if (!this.getOwned(instanceId, accountId))
|
|
328
|
+
return false;
|
|
329
|
+
this.db.run("DELETE FROM instances WHERE id = ?", [instanceId]);
|
|
330
|
+
return true;
|
|
331
|
+
}
|
|
332
|
+
prunePairingTokens(now) {
|
|
333
|
+
const iso = now.toISOString();
|
|
334
|
+
const row = this.db.get("SELECT COUNT(*) AS n FROM pairing_tokens WHERE expires_at <= ? OR used_at IS NOT NULL", [iso]);
|
|
335
|
+
this.db.run("DELETE FROM pairing_tokens WHERE expires_at <= ? OR used_at IS NOT NULL", [iso]);
|
|
336
|
+
return row?.n ?? 0;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
function toInstanceRow(row) {
|
|
340
|
+
return {
|
|
341
|
+
id: row.id,
|
|
342
|
+
accountId: row.account_id,
|
|
343
|
+
name: row.name,
|
|
344
|
+
coreVersion: row.core_version,
|
|
345
|
+
lastSeenAt: row.last_seen_at,
|
|
346
|
+
createdAt: row.created_at
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// packages/relay/src/stores/messages.ts
|
|
351
|
+
class MessageStore {
|
|
352
|
+
db;
|
|
353
|
+
now;
|
|
354
|
+
constructor(db, now = () => new Date) {
|
|
355
|
+
this.db = db;
|
|
356
|
+
this.now = now;
|
|
357
|
+
}
|
|
358
|
+
append(instanceId, sessionAlias, direction, text, structured) {
|
|
359
|
+
this.db.run("INSERT INTO messages (instance_id, session_alias, direction, text, created_at, structured) VALUES (?,?,?,?,?,?)", [instanceId, sessionAlias, direction, text, this.now().toISOString(), structured ? JSON.stringify(structured) : null]);
|
|
360
|
+
}
|
|
361
|
+
listBySession(accountId, instanceId, sessionAlias, opts = {}) {
|
|
362
|
+
const limit = opts.limit ?? 100;
|
|
363
|
+
const before = opts.before ?? null;
|
|
364
|
+
const rows = this.db.all(`SELECT m.id, m.instance_id, m.session_alias, m.direction, m.text, m.created_at, m.structured
|
|
365
|
+
FROM messages m JOIN instances i ON i.id = m.instance_id
|
|
366
|
+
WHERE i.account_id = ? AND m.instance_id = ? AND m.session_alias = ?
|
|
367
|
+
AND (? IS NULL OR m.id < ?)
|
|
368
|
+
ORDER BY m.id DESC LIMIT ?`, [accountId, instanceId, sessionAlias, before, before, limit + 1]);
|
|
369
|
+
const hasMore = rows.length > limit;
|
|
370
|
+
const page = hasMore ? rows.slice(0, limit) : rows;
|
|
371
|
+
return {
|
|
372
|
+
hasMore,
|
|
373
|
+
messages: page.reverse().map((r) => ({
|
|
374
|
+
id: r.id,
|
|
375
|
+
instanceId: r.instance_id,
|
|
376
|
+
sessionAlias: r.session_alias,
|
|
377
|
+
direction: r.direction,
|
|
378
|
+
text: r.text,
|
|
379
|
+
createdAt: r.created_at,
|
|
380
|
+
...r.structured ? { structured: JSON.parse(r.structured) } : {}
|
|
381
|
+
}))
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
prune(opts) {
|
|
385
|
+
let deleted = 0;
|
|
386
|
+
if (opts.maxAgeMs !== undefined) {
|
|
387
|
+
const cutoff = new Date(this.now().getTime() - opts.maxAgeMs).toISOString();
|
|
388
|
+
const before = this.db.get("SELECT COUNT(*) AS n FROM messages WHERE created_at < ?", [cutoff]);
|
|
389
|
+
this.db.run("DELETE FROM messages WHERE created_at < ?", [cutoff]);
|
|
390
|
+
deleted += before?.n ?? 0;
|
|
391
|
+
}
|
|
392
|
+
if (opts.maxPerSession !== undefined) {
|
|
393
|
+
const groups = this.db.all("SELECT instance_id, session_alias FROM messages GROUP BY instance_id, session_alias HAVING COUNT(*) > ?", [opts.maxPerSession]);
|
|
394
|
+
for (const g of groups) {
|
|
395
|
+
const before = this.db.get("SELECT COUNT(*) AS n FROM messages WHERE instance_id = ? AND session_alias = ?", [g.instance_id, g.session_alias]);
|
|
396
|
+
this.db.run(`DELETE FROM messages WHERE instance_id = ? AND session_alias = ? AND id NOT IN (
|
|
397
|
+
SELECT id FROM messages WHERE instance_id = ? AND session_alias = ? ORDER BY id DESC LIMIT ?
|
|
398
|
+
)`, [g.instance_id, g.session_alias, g.instance_id, g.session_alias, opts.maxPerSession]);
|
|
399
|
+
deleted += Math.max(0, (before?.n ?? 0) - opts.maxPerSession);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return deleted;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// packages/relay/src/gateway/instance-gateway.ts
|
|
407
|
+
import {
|
|
408
|
+
MSG,
|
|
409
|
+
RELAY_PROTOCOL_VERSION,
|
|
410
|
+
decodeEnvelope,
|
|
411
|
+
encodeEnvelope,
|
|
412
|
+
errorPayload
|
|
413
|
+
} from "@ganglion/xacpx-relay-protocol";
|
|
414
|
+
var DEFAULT_REQUEST_TIMEOUT_MS = 120000;
|
|
415
|
+
|
|
416
|
+
class InstanceGateway {
|
|
417
|
+
deps;
|
|
418
|
+
connections = new Map;
|
|
419
|
+
pending = new Map;
|
|
420
|
+
seq = 0;
|
|
421
|
+
constructor(deps) {
|
|
422
|
+
this.deps = deps;
|
|
423
|
+
}
|
|
424
|
+
isOnline(instanceId) {
|
|
425
|
+
return this.connections.has(instanceId);
|
|
426
|
+
}
|
|
427
|
+
handleConnection(socket) {
|
|
428
|
+
let authed = null;
|
|
429
|
+
socket.on("message", (data) => {
|
|
430
|
+
const decoded = decodeEnvelope(String(data));
|
|
431
|
+
if (!decoded.ok) {
|
|
432
|
+
socket.send(encodeEnvelope({
|
|
433
|
+
protocolVersion: RELAY_PROTOCOL_VERSION,
|
|
434
|
+
kind: "event",
|
|
435
|
+
type: "relay.protocol-error",
|
|
436
|
+
payload: errorPayload(decoded.error, decoded.detail ?? "invalid envelope")
|
|
437
|
+
}));
|
|
438
|
+
if (!authed)
|
|
439
|
+
socket.close(4400, decoded.error);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
const envelope = decoded.envelope;
|
|
443
|
+
if (!authed) {
|
|
444
|
+
authed = this.handleHandshake(socket, envelope);
|
|
445
|
+
if (authed) {
|
|
446
|
+
this.connections.set(authed.instanceId, { socket, accountId: authed.accountId });
|
|
447
|
+
this.deps.onStatusChange?.(authed.instanceId, authed.accountId, true);
|
|
448
|
+
}
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
if (envelope.kind === "res" && envelope.id) {
|
|
452
|
+
const waiting = this.pending.get(envelope.id);
|
|
453
|
+
if (waiting) {
|
|
454
|
+
clearTimeout(waiting.timer);
|
|
455
|
+
this.pending.delete(envelope.id);
|
|
456
|
+
waiting.resolve(envelope.payload);
|
|
457
|
+
}
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
if (envelope.kind === "event") {
|
|
461
|
+
this.deps.instances.touch(authed.instanceId);
|
|
462
|
+
this.deps.onEvent?.(authed.instanceId, authed.accountId, envelope);
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
socket.on("close", () => {
|
|
466
|
+
if (authed) {
|
|
467
|
+
this.connections.delete(authed.instanceId);
|
|
468
|
+
for (const [id, p] of this.pending) {
|
|
469
|
+
if (p.instanceId === authed.instanceId) {
|
|
470
|
+
clearTimeout(p.timer);
|
|
471
|
+
this.pending.delete(id);
|
|
472
|
+
p.reject(new Error("instance-offline"));
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
this.deps.onStatusChange?.(authed.instanceId, authed.accountId, false);
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
handleHandshake(socket, envelope) {
|
|
480
|
+
const respond = (payload) => {
|
|
481
|
+
socket.send(encodeEnvelope({
|
|
482
|
+
protocolVersion: RELAY_PROTOCOL_VERSION,
|
|
483
|
+
kind: "res",
|
|
484
|
+
id: envelope.id ?? "handshake",
|
|
485
|
+
type: envelope.type,
|
|
486
|
+
payload
|
|
487
|
+
}));
|
|
488
|
+
};
|
|
489
|
+
if (envelope.kind !== "req") {
|
|
490
|
+
socket.close(4401, "unauthenticated");
|
|
491
|
+
return null;
|
|
492
|
+
}
|
|
493
|
+
if (envelope.type === MSG.instanceRegister) {
|
|
494
|
+
const payload = envelope.payload;
|
|
495
|
+
const presented = payload?.pairingToken ?? "";
|
|
496
|
+
const viaLogin = this.deps.accounts.resolveLoginToken(presented);
|
|
497
|
+
let result;
|
|
498
|
+
if (viaLogin) {
|
|
499
|
+
result = this.deps.instances.registerInstanceForAccount(viaLogin.account.id, payload?.name, payload?.coreVersion);
|
|
500
|
+
} else {
|
|
501
|
+
const redeemed = this.deps.instances.redeemPairingToken(presented, payload?.coreVersion);
|
|
502
|
+
if (!redeemed) {
|
|
503
|
+
respond(errorPayload("pairing-failed", "token is invalid, expired, or already used"));
|
|
504
|
+
return null;
|
|
505
|
+
}
|
|
506
|
+
result = redeemed;
|
|
507
|
+
}
|
|
508
|
+
respond({ instanceId: result.instanceId, credential: result.credential });
|
|
509
|
+
this.deps.instances.touch(result.instanceId);
|
|
510
|
+
return { instanceId: result.instanceId, accountId: result.accountId };
|
|
511
|
+
}
|
|
512
|
+
if (envelope.type === MSG.instanceAuth) {
|
|
513
|
+
const payload = envelope.payload;
|
|
514
|
+
const instance = this.deps.instances.verifyCredential(payload?.instanceId ?? "", payload?.credential ?? "");
|
|
515
|
+
if (!instance) {
|
|
516
|
+
respond(errorPayload("auth-failed", "unknown instance or bad credential"));
|
|
517
|
+
socket.close(4403, "auth-failed");
|
|
518
|
+
return null;
|
|
519
|
+
}
|
|
520
|
+
respond({ ok: true });
|
|
521
|
+
this.deps.instances.touch(instance.id, payload?.coreVersion);
|
|
522
|
+
return { instanceId: instance.id, accountId: instance.accountId };
|
|
523
|
+
}
|
|
524
|
+
socket.close(4401, "unauthenticated");
|
|
525
|
+
return null;
|
|
526
|
+
}
|
|
527
|
+
async sendRequest(instanceId, type, payload) {
|
|
528
|
+
const connection = this.connections.get(instanceId);
|
|
529
|
+
if (!connection) {
|
|
530
|
+
throw new Error("instance-offline");
|
|
531
|
+
}
|
|
532
|
+
const id = `relay-${++this.seq}`;
|
|
533
|
+
const timeoutMs = this.deps.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
534
|
+
return await new Promise((resolve, reject) => {
|
|
535
|
+
const timer = setTimeout(() => {
|
|
536
|
+
this.pending.delete(id);
|
|
537
|
+
reject(new Error("timeout"));
|
|
538
|
+
}, timeoutMs);
|
|
539
|
+
this.pending.set(id, { resolve, reject, timer, instanceId });
|
|
540
|
+
connection.socket.send(encodeEnvelope({
|
|
541
|
+
protocolVersion: RELAY_PROTOCOL_VERSION,
|
|
542
|
+
kind: "req",
|
|
543
|
+
id,
|
|
544
|
+
type,
|
|
545
|
+
payload
|
|
546
|
+
}));
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// packages/relay/src/gateway/web-gateway.ts
|
|
552
|
+
import { encodeEnvelope as encodeEnvelope2, webEventEnvelope } from "@ganglion/xacpx-relay-protocol";
|
|
553
|
+
|
|
554
|
+
class WebGateway {
|
|
555
|
+
byAccount = new Map;
|
|
556
|
+
register(accountId, socket) {
|
|
557
|
+
const set = this.byAccount.get(accountId) ?? new Set;
|
|
558
|
+
set.add(socket);
|
|
559
|
+
this.byAccount.set(accountId, set);
|
|
560
|
+
socket.on("close", () => {
|
|
561
|
+
set.delete(socket);
|
|
562
|
+
if (set.size === 0)
|
|
563
|
+
this.byAccount.delete(accountId);
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
broadcast(accountId, event) {
|
|
567
|
+
const set = this.byAccount.get(accountId);
|
|
568
|
+
if (!set)
|
|
569
|
+
return;
|
|
570
|
+
const data = encodeEnvelope2(webEventEnvelope(event));
|
|
571
|
+
for (const socket of set)
|
|
572
|
+
socket.send(data);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// packages/relay/src/http/app.ts
|
|
577
|
+
import { Hono } from "hono";
|
|
578
|
+
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
|
|
579
|
+
import { serveStatic } from "@hono/node-server/serve-static";
|
|
580
|
+
import { MSG as MSG2 } from "@ganglion/xacpx-relay-protocol";
|
|
581
|
+
|
|
582
|
+
// packages/relay/src/http/client-ip.ts
|
|
583
|
+
import { getConnInfo } from "@hono/node-server/conninfo";
|
|
584
|
+
function clientIp(c, trustProxy) {
|
|
585
|
+
if (trustProxy) {
|
|
586
|
+
const xff = c.req.header("x-forwarded-for");
|
|
587
|
+
if (xff)
|
|
588
|
+
return xff.split(",")[0].trim();
|
|
589
|
+
}
|
|
590
|
+
try {
|
|
591
|
+
return getConnInfo(c).remote.address ?? "unknown";
|
|
592
|
+
} catch {
|
|
593
|
+
return "unknown";
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// packages/relay/src/http/app.ts
|
|
598
|
+
var SESSION_COOKIE = "xrelay_session";
|
|
599
|
+
var LOGIN_WINDOW_MS = 10 * 60 * 1000;
|
|
600
|
+
var LOGIN_MAX_FAILURES = 10;
|
|
601
|
+
var LOGIN_FAILURES_SWEEP_AT = 1024;
|
|
602
|
+
var LOGIN_FAILURES_MAX = 4096;
|
|
603
|
+
var GLOBAL_MAX_FAILURES = 200;
|
|
604
|
+
var CHAT_SCOPED_TYPES = new Set([
|
|
605
|
+
MSG2.prompt,
|
|
606
|
+
MSG2.promptCancel,
|
|
607
|
+
MSG2.commandExecute,
|
|
608
|
+
MSG2.scheduledList,
|
|
609
|
+
MSG2.scheduledCreate,
|
|
610
|
+
MSG2.scheduledCancel,
|
|
611
|
+
MSG2.sessionsList,
|
|
612
|
+
MSG2.sessionsCreate,
|
|
613
|
+
MSG2.sessionsNativeList,
|
|
614
|
+
MSG2.sessionsRemove,
|
|
615
|
+
MSG2.sessionModelGet,
|
|
616
|
+
MSG2.sessionModelSet
|
|
617
|
+
]);
|
|
618
|
+
function requireJson(contentType) {
|
|
619
|
+
return (contentType ?? "").toLowerCase().includes("application/json");
|
|
620
|
+
}
|
|
621
|
+
function createApp(deps) {
|
|
622
|
+
const sessionTtlMs = deps.sessionTtlMs ?? 7 * 24 * 60 * 60 * 1000;
|
|
623
|
+
const pairingTtlMs = deps.pairingTtlMs ?? 10 * 60 * 1000;
|
|
624
|
+
const trustProxy = deps.trustProxy ?? false;
|
|
625
|
+
const now = deps.now ?? (() => new Date);
|
|
626
|
+
const loginFailures = new Map;
|
|
627
|
+
let globalFailures = { count: 0, windowStart: 0 };
|
|
628
|
+
function sweepPerIpLoginFailures(nowMs) {
|
|
629
|
+
if (loginFailures.size > LOGIN_FAILURES_SWEEP_AT) {
|
|
630
|
+
for (const [k, v] of loginFailures) {
|
|
631
|
+
if (nowMs - v.windowStart >= LOGIN_WINDOW_MS)
|
|
632
|
+
loginFailures.delete(k);
|
|
633
|
+
}
|
|
634
|
+
if (loginFailures.size > LOGIN_FAILURES_MAX) {
|
|
635
|
+
const sorted = [...loginFailures.entries()].sort((a, b) => a[1].windowStart - b[1].windowStart);
|
|
636
|
+
for (let i = 0;i < sorted.length && loginFailures.size > LOGIN_FAILURES_MAX; i++) {
|
|
637
|
+
const entry = sorted[i];
|
|
638
|
+
if (entry)
|
|
639
|
+
loginFailures.delete(entry[0]);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
function recordFailure(ip, nowMs) {
|
|
645
|
+
const perIp = loginFailures.get(ip);
|
|
646
|
+
const ipEntry = perIp && nowMs - perIp.windowStart < LOGIN_WINDOW_MS ? { count: perIp.count + 1, windowStart: perIp.windowStart } : { count: 1, windowStart: nowMs };
|
|
647
|
+
loginFailures.set(ip, ipEntry);
|
|
648
|
+
if (nowMs - globalFailures.windowStart >= LOGIN_WINDOW_MS) {
|
|
649
|
+
globalFailures = { count: 1, windowStart: nowMs };
|
|
650
|
+
} else {
|
|
651
|
+
globalFailures = { count: globalFailures.count + 1, windowStart: globalFailures.windowStart };
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
function isRateLimited(ip, nowMs) {
|
|
655
|
+
if (nowMs - globalFailures.windowStart < LOGIN_WINDOW_MS && globalFailures.count >= GLOBAL_MAX_FAILURES) {
|
|
656
|
+
return true;
|
|
657
|
+
}
|
|
658
|
+
const perIp = loginFailures.get(ip);
|
|
659
|
+
return !!(perIp && nowMs - perIp.windowStart < LOGIN_WINDOW_MS && perIp.count >= LOGIN_MAX_FAILURES);
|
|
660
|
+
}
|
|
661
|
+
const app = new Hono;
|
|
662
|
+
app.post("/api/login", async (c) => {
|
|
663
|
+
if (!requireJson(c.req.header("content-type")))
|
|
664
|
+
return c.json({ error: "unsupported-media-type" }, 415);
|
|
665
|
+
const body = await c.req.json().catch(() => ({}));
|
|
666
|
+
const nowMs = now().getTime();
|
|
667
|
+
sweepPerIpLoginFailures(nowMs);
|
|
668
|
+
const ip = clientIp(c, trustProxy);
|
|
669
|
+
if (isRateLimited(ip, nowMs)) {
|
|
670
|
+
return c.json({ error: "too-many-attempts" }, 429);
|
|
671
|
+
}
|
|
672
|
+
const r = deps.accounts.resolveLoginToken(body.token ?? "");
|
|
673
|
+
if (!r) {
|
|
674
|
+
recordFailure(ip, nowMs);
|
|
675
|
+
return c.json({ error: "invalid-token" }, 401);
|
|
676
|
+
}
|
|
677
|
+
const sess = deps.accounts.createWebSession(r.account.id, r.loginTokenId, sessionTtlMs);
|
|
678
|
+
setCookie(c, SESSION_COOKIE, sess, {
|
|
679
|
+
httpOnly: true,
|
|
680
|
+
sameSite: "Lax",
|
|
681
|
+
path: "/",
|
|
682
|
+
maxAge: Math.floor(sessionTtlMs / 1000)
|
|
683
|
+
});
|
|
684
|
+
return c.json({ username: r.account.username });
|
|
685
|
+
});
|
|
686
|
+
app.post("/api/register", (c) => c.json({ error: "not-found" }, 404));
|
|
687
|
+
app.post("/api/invites", (c) => c.json({ error: "not-found" }, 404));
|
|
688
|
+
app.use("/api/*", async (c, next) => {
|
|
689
|
+
if (c.req.path === "/api/login")
|
|
690
|
+
return next();
|
|
691
|
+
const token = getCookie(c, SESSION_COOKIE);
|
|
692
|
+
const account = token ? deps.accounts.getSessionAccount(token) : null;
|
|
693
|
+
if (!account)
|
|
694
|
+
return c.json({ error: "unauthorized" }, 401);
|
|
695
|
+
c.set("account", account);
|
|
696
|
+
return next();
|
|
697
|
+
});
|
|
698
|
+
app.post("/api/logout", (c) => {
|
|
699
|
+
const token = getCookie(c, SESSION_COOKIE);
|
|
700
|
+
if (token)
|
|
701
|
+
deps.accounts.deleteWebSession(token);
|
|
702
|
+
deleteCookie(c, SESSION_COOKIE, { path: "/" });
|
|
703
|
+
return c.json({ ok: true });
|
|
704
|
+
});
|
|
705
|
+
app.get("/api/me", (c) => {
|
|
706
|
+
const account = c.get("account");
|
|
707
|
+
return c.json({ username: account.username });
|
|
708
|
+
});
|
|
709
|
+
app.get("/api/config", (c) => {
|
|
710
|
+
return c.json({
|
|
711
|
+
historyRetention: {
|
|
712
|
+
days: deps.historyRetentionDays ?? 30,
|
|
713
|
+
maxPerSession: deps.maxMessagesPerSession ?? 2000
|
|
714
|
+
}
|
|
715
|
+
});
|
|
716
|
+
});
|
|
717
|
+
app.get("/api/instances", (c) => {
|
|
718
|
+
const account = c.get("account");
|
|
719
|
+
const rows = deps.instances.listByAccount(account.id).map((row) => ({
|
|
720
|
+
...row,
|
|
721
|
+
online: deps.gateway.isOnline(row.id)
|
|
722
|
+
}));
|
|
723
|
+
return c.json({ instances: rows });
|
|
724
|
+
});
|
|
725
|
+
app.post("/api/instances/pairing-token", async (c) => {
|
|
726
|
+
if (!requireJson(c.req.header("content-type")))
|
|
727
|
+
return c.json({ error: "unsupported-media-type" }, 415);
|
|
728
|
+
const account = c.get("account");
|
|
729
|
+
const body = await c.req.json().catch(() => ({}));
|
|
730
|
+
const issued = deps.instances.issuePairingToken(account.id, body.name, pairingTtlMs);
|
|
731
|
+
return c.json({ token: issued.token, expiresAt: issued.expiresAt });
|
|
732
|
+
});
|
|
733
|
+
app.delete("/api/instances/:id", (c) => {
|
|
734
|
+
const account = c.get("account");
|
|
735
|
+
const removed = deps.instances.remove(c.req.param("id"), account.id);
|
|
736
|
+
return removed ? c.json({ ok: true }) : c.json({ error: "not-found" }, 404);
|
|
737
|
+
});
|
|
738
|
+
app.get("/api/active-turns", (c) => {
|
|
739
|
+
const account = c.get("account");
|
|
740
|
+
const turns = [];
|
|
741
|
+
for (const inst of deps.instances.listByAccount(account.id)) {
|
|
742
|
+
for (const t of deps.activeTurns?.(inst.id) ?? [])
|
|
743
|
+
turns.push(t);
|
|
744
|
+
}
|
|
745
|
+
return c.json({ turns });
|
|
746
|
+
});
|
|
747
|
+
app.get("/api/instances/:id/sessions/:alias/messages", (c) => {
|
|
748
|
+
const account = c.get("account");
|
|
749
|
+
const instance = deps.instances.getOwned(c.req.param("id"), account.id);
|
|
750
|
+
if (!instance)
|
|
751
|
+
return c.json({ error: "not-found" }, 404);
|
|
752
|
+
const limitRaw = Number(c.req.query("limit"));
|
|
753
|
+
const beforeRaw = Number(c.req.query("before"));
|
|
754
|
+
const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(Math.floor(limitRaw), 200) : 100;
|
|
755
|
+
const before = Number.isFinite(beforeRaw) && beforeRaw > 0 ? Math.floor(beforeRaw) : undefined;
|
|
756
|
+
const page = deps.messages.listBySession(account.id, instance.id, c.req.param("alias"), {
|
|
757
|
+
limit,
|
|
758
|
+
...before !== undefined ? { before } : {}
|
|
759
|
+
});
|
|
760
|
+
return c.json(page);
|
|
761
|
+
});
|
|
762
|
+
app.post("/api/instances/:id/rpc", async (c) => {
|
|
763
|
+
if (!requireJson(c.req.header("content-type")))
|
|
764
|
+
return c.json({ error: "unsupported-media-type" }, 415);
|
|
765
|
+
const account = c.get("account");
|
|
766
|
+
const instance = deps.instances.getOwned(c.req.param("id"), account.id);
|
|
767
|
+
if (!instance)
|
|
768
|
+
return c.json({ error: "not-found" }, 404);
|
|
769
|
+
const body = await c.req.json().catch(() => ({}));
|
|
770
|
+
if (!body.type || !body.type.startsWith("control."))
|
|
771
|
+
return c.json({ error: "invalid-rpc-type" }, 400);
|
|
772
|
+
let payload = body.payload ?? {};
|
|
773
|
+
if (CHAT_SCOPED_TYPES.has(body.type)) {
|
|
774
|
+
payload = {
|
|
775
|
+
...payload,
|
|
776
|
+
chatKey: `relay:${account.id}`,
|
|
777
|
+
senderId: account.id,
|
|
778
|
+
isOwner: true
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
try {
|
|
782
|
+
if (body.type === MSG2.prompt || body.type === MSG2.commandExecute) {
|
|
783
|
+
const p = payload;
|
|
784
|
+
if (p.sessionAlias && p.text)
|
|
785
|
+
deps.messages.append(instance.id, p.sessionAlias, "in", p.text);
|
|
786
|
+
}
|
|
787
|
+
const result = await deps.gateway.sendRequest(instance.id, body.type, payload);
|
|
788
|
+
if (body.type === MSG2.commandExecute) {
|
|
789
|
+
const p = payload;
|
|
790
|
+
const output = result?.output;
|
|
791
|
+
if (p.sessionAlias && typeof output === "string")
|
|
792
|
+
deps.messages.append(instance.id, p.sessionAlias, "out", output);
|
|
793
|
+
}
|
|
794
|
+
return c.json({ result });
|
|
795
|
+
} catch (error) {
|
|
796
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
797
|
+
if (message === "instance-offline")
|
|
798
|
+
return c.json({ error: message }, 503);
|
|
799
|
+
if (message === "timeout")
|
|
800
|
+
return c.json({ error: message }, 504);
|
|
801
|
+
return c.json({ error: message }, 500);
|
|
802
|
+
}
|
|
803
|
+
});
|
|
804
|
+
if (deps.webRoot) {
|
|
805
|
+
const root = deps.webRoot;
|
|
806
|
+
app.use("/*", serveStatic({ root }));
|
|
807
|
+
app.get("/*", serveStatic({ path: "index.html", root }));
|
|
808
|
+
}
|
|
809
|
+
return app;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// packages/relay/src/maintenance.ts
|
|
813
|
+
function runMaintenance(stores, opts) {
|
|
814
|
+
const now = (opts.now ?? (() => new Date))();
|
|
815
|
+
const messagesDeleted = stores.messages.prune({
|
|
816
|
+
maxAgeMs: opts.historyRetentionDays * 24 * 60 * 60 * 1000,
|
|
817
|
+
maxPerSession: opts.maxPerSession
|
|
818
|
+
});
|
|
819
|
+
const sessionsDeleted = stores.accounts.pruneExpired(now);
|
|
820
|
+
const pairingTokensDeleted = stores.instances.prunePairingTokens(now);
|
|
821
|
+
return { messagesDeleted, sessionsDeleted, pairingTokensDeleted };
|
|
822
|
+
}
|
|
823
|
+
function startMaintenanceLoop(stores, opts, intervalMs, onError) {
|
|
824
|
+
const tick = () => {
|
|
825
|
+
try {
|
|
826
|
+
runMaintenance(stores, opts);
|
|
827
|
+
} catch (err) {
|
|
828
|
+
onError?.(err);
|
|
829
|
+
}
|
|
830
|
+
};
|
|
831
|
+
const timer = setInterval(tick, intervalMs);
|
|
832
|
+
if (typeof timer === "object" && timer && "unref" in timer)
|
|
833
|
+
timer.unref();
|
|
834
|
+
return () => clearInterval(timer);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// packages/relay/src/server.ts
|
|
838
|
+
var MAX_MESSAGES_PER_SESSION = 2000;
|
|
839
|
+
var MAX_TOOL_STEPS = 200;
|
|
840
|
+
var REASONING_CAP = 16000;
|
|
841
|
+
async function createRelayRuntime(dbPath, options = {}) {
|
|
842
|
+
const db = await createSqlDriver(dbPath);
|
|
843
|
+
initSchema(db);
|
|
844
|
+
const accounts = new AccountStore(db);
|
|
845
|
+
const instances = new InstanceStore(db);
|
|
846
|
+
const messages = new MessageStore(db);
|
|
847
|
+
const webGateway = new WebGateway;
|
|
848
|
+
const turnBuffers = new Map;
|
|
849
|
+
const key = (instanceId, alias) => `${instanceId}\x00${alias}`;
|
|
850
|
+
const listActiveTurns = (instanceId) => {
|
|
851
|
+
const prefix = `${instanceId}\x00`;
|
|
852
|
+
const out = [];
|
|
853
|
+
for (const [k, a] of turnBuffers) {
|
|
854
|
+
if (!k.startsWith(prefix))
|
|
855
|
+
continue;
|
|
856
|
+
out.push({
|
|
857
|
+
instanceId,
|
|
858
|
+
sessionAlias: k.slice(prefix.length),
|
|
859
|
+
parts: a.parts,
|
|
860
|
+
status: a.text ? "streaming" : "working",
|
|
861
|
+
startedAt: a.startedAt
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
return out;
|
|
865
|
+
};
|
|
866
|
+
const pushTextPart = (a, chunk) => {
|
|
867
|
+
const last = a.parts[a.parts.length - 1];
|
|
868
|
+
if (last?.type === "text")
|
|
869
|
+
last.text += chunk;
|
|
870
|
+
else
|
|
871
|
+
a.parts.push({ type: "text", text: chunk });
|
|
872
|
+
};
|
|
873
|
+
const pushReasoningPart = (a, chunk) => {
|
|
874
|
+
const last = a.parts[a.parts.length - 1];
|
|
875
|
+
if (last?.type === "reasoning")
|
|
876
|
+
last.text = (last.text + chunk).slice(0, REASONING_CAP);
|
|
877
|
+
else
|
|
878
|
+
a.parts.push({ type: "reasoning", text: chunk.slice(0, REASONING_CAP) });
|
|
879
|
+
};
|
|
880
|
+
const pushToolPart = (a, step) => {
|
|
881
|
+
const i = a.parts.findIndex((p) => p.type === "tool" && p.step.toolCallId === step.toolCallId);
|
|
882
|
+
if (i >= 0)
|
|
883
|
+
a.parts[i].step = step;
|
|
884
|
+
else
|
|
885
|
+
a.parts.push({ type: "tool", step });
|
|
886
|
+
};
|
|
887
|
+
const gateway = new InstanceGateway({
|
|
888
|
+
instances,
|
|
889
|
+
accounts,
|
|
890
|
+
requestTimeoutMs: options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
|
|
891
|
+
onStatusChange: (instanceId, accountId, online) => {
|
|
892
|
+
if (!online) {
|
|
893
|
+
const prefix = `${instanceId}\x00`;
|
|
894
|
+
for (const k of turnBuffers.keys())
|
|
895
|
+
if (k.startsWith(prefix))
|
|
896
|
+
turnBuffers.delete(k);
|
|
897
|
+
}
|
|
898
|
+
webGateway.broadcast(accountId, { kind: "instance-status", instanceId, online });
|
|
899
|
+
},
|
|
900
|
+
onEvent: (instanceId, accountId, envelope) => {
|
|
901
|
+
if (envelope.type === MSG3.instanceEvent) {
|
|
902
|
+
const event = envelope.payload.event;
|
|
903
|
+
webGateway.broadcast(accountId, { kind: "control-event", instanceId, event });
|
|
904
|
+
if (event.type === "turn-started") {
|
|
905
|
+
turnBuffers.set(key(instanceId, event.sessionAlias), { text: "", steps: new Map, reasoning: "", parts: [], startedAt: Date.now() });
|
|
906
|
+
if (event.prompt)
|
|
907
|
+
messages.append(instanceId, event.sessionAlias, "in", event.prompt, event.scheduled ? { scheduled: event.scheduled } : undefined);
|
|
908
|
+
} else if (event.type === "turn-output") {
|
|
909
|
+
const a = turnBuffers.get(key(instanceId, event.sessionAlias));
|
|
910
|
+
if (a) {
|
|
911
|
+
a.text += event.chunk;
|
|
912
|
+
pushTextPart(a, event.chunk);
|
|
913
|
+
}
|
|
914
|
+
} else if (event.type === "tool-event") {
|
|
915
|
+
const a = turnBuffers.get(key(instanceId, event.sessionAlias));
|
|
916
|
+
if (a && (a.steps.has(event.step.toolCallId) || a.steps.size < MAX_TOOL_STEPS)) {
|
|
917
|
+
a.steps.set(event.step.toolCallId, event.step);
|
|
918
|
+
pushToolPart(a, event.step);
|
|
919
|
+
}
|
|
920
|
+
} else if (event.type === "turn-thought") {
|
|
921
|
+
const a = turnBuffers.get(key(instanceId, event.sessionAlias));
|
|
922
|
+
if (a) {
|
|
923
|
+
a.reasoning = (a.reasoning + event.chunk).slice(0, REASONING_CAP);
|
|
924
|
+
pushReasoningPart(a, event.chunk);
|
|
925
|
+
}
|
|
926
|
+
} else if (event.type === "turn-finished") {
|
|
927
|
+
const k = key(instanceId, event.sessionAlias);
|
|
928
|
+
const a = turnBuffers.get(k);
|
|
929
|
+
turnBuffers.delete(k);
|
|
930
|
+
if (!a)
|
|
931
|
+
return;
|
|
932
|
+
const steps = [...a.steps.values()];
|
|
933
|
+
const hasStructured = steps.length > 0 || a.reasoning.length > 0;
|
|
934
|
+
if (a.text || hasStructured) {
|
|
935
|
+
const structured = hasStructured ? { toolSteps: steps, ...a.reasoning ? { reasoning: a.reasoning } : {}, ...a.parts.length ? { parts: a.parts } : {} } : undefined;
|
|
936
|
+
messages.append(instanceId, event.sessionAlias, "out", a.text, structured);
|
|
937
|
+
}
|
|
938
|
+
} else if (event.type === "session-history") {
|
|
939
|
+
const existing = messages.listBySession(accountId, instanceId, event.sessionAlias, { limit: 1 });
|
|
940
|
+
if (existing.messages.length === 0) {
|
|
941
|
+
for (const row of event.messages) {
|
|
942
|
+
messages.append(instanceId, event.sessionAlias, row.direction, row.text, row.structured);
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
} else if (envelope.type === MSG3.instanceNotice) {
|
|
947
|
+
webGateway.broadcast(accountId, { kind: "notice", instanceId, notice: envelope.payload });
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
});
|
|
951
|
+
const app = createApp({
|
|
952
|
+
accounts,
|
|
953
|
+
instances,
|
|
954
|
+
messages,
|
|
955
|
+
gateway,
|
|
956
|
+
webRoot: options.webRoot,
|
|
957
|
+
historyRetentionDays: options.historyRetentionDays ?? 30,
|
|
958
|
+
maxMessagesPerSession: MAX_MESSAGES_PER_SESSION,
|
|
959
|
+
activeTurns: listActiveTurns,
|
|
960
|
+
trustProxy: options.trustProxy
|
|
961
|
+
});
|
|
962
|
+
return { db, accounts, instances, messages, gateway, webGateway, app, close: () => db.close() };
|
|
963
|
+
}
|
|
964
|
+
async function startRelayServer(options) {
|
|
965
|
+
const runtime = await createRelayRuntime(options.dbPath, {
|
|
966
|
+
webRoot: options.webRoot,
|
|
967
|
+
historyRetentionDays: options.historyRetentionDays,
|
|
968
|
+
requestTimeoutMs: options.requestTimeoutMs,
|
|
969
|
+
trustProxy: options.trustProxy
|
|
970
|
+
});
|
|
971
|
+
const host = options.host ?? "0.0.0.0";
|
|
972
|
+
const retention = { historyRetentionDays: options.historyRetentionDays ?? 30, maxPerSession: MAX_MESSAGES_PER_SESSION };
|
|
973
|
+
const stopMaintenance = startMaintenanceLoop({ accounts: runtime.accounts, instances: runtime.instances, messages: runtime.messages }, retention, 60 * 60 * 1000);
|
|
974
|
+
const httpServer = await new Promise((resolve, reject) => {
|
|
975
|
+
let server;
|
|
976
|
+
try {
|
|
977
|
+
server = serve({ fetch: runtime.app.fetch, port: options.httpPort, hostname: host }, () => resolve(server));
|
|
978
|
+
} catch (err) {
|
|
979
|
+
reject(err);
|
|
980
|
+
}
|
|
981
|
+
});
|
|
982
|
+
const wss = new WebSocketServer({ port: options.wsPort, host });
|
|
983
|
+
await new Promise((resolve) => wss.on("listening", () => resolve()));
|
|
984
|
+
wss.on("connection", (socket) => runtime.gateway.handleConnection(socket));
|
|
985
|
+
const webWss = new WebSocketServer({ noServer: true });
|
|
986
|
+
httpServer.on("upgrade", (req, socket, head) => {
|
|
987
|
+
const path = (req.url ?? "").split("?")[0];
|
|
988
|
+
if (path !== "/ws") {
|
|
989
|
+
socket.destroy();
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
const token = parseCookie(req.headers.cookie ?? "")["xrelay_session"];
|
|
993
|
+
const account = token ? runtime.accounts.getSessionAccount(token) : null;
|
|
994
|
+
if (!account) {
|
|
995
|
+
socket.destroy();
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
webWss.handleUpgrade(req, socket, head, (ws) => runtime.webGateway.register(account.id, ws));
|
|
999
|
+
});
|
|
1000
|
+
const httpPort = httpServer.address().port;
|
|
1001
|
+
const wsPort = wss.address().port;
|
|
1002
|
+
return {
|
|
1003
|
+
runtime,
|
|
1004
|
+
httpPort,
|
|
1005
|
+
wsPort,
|
|
1006
|
+
close: async () => {
|
|
1007
|
+
stopMaintenance();
|
|
1008
|
+
await new Promise((resolve) => webWss.close(() => resolve()));
|
|
1009
|
+
await new Promise((resolve) => wss.close(() => resolve()));
|
|
1010
|
+
await new Promise((resolve) => httpServer.close(() => resolve()));
|
|
1011
|
+
runtime.close();
|
|
1012
|
+
}
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
function parseCookie(header) {
|
|
1016
|
+
const out = {};
|
|
1017
|
+
for (const part of header.split(";")) {
|
|
1018
|
+
const idx = part.indexOf("=");
|
|
1019
|
+
if (idx === -1)
|
|
1020
|
+
continue;
|
|
1021
|
+
out[part.slice(0, idx).trim()] = decodeURIComponent(part.slice(idx + 1).trim());
|
|
1022
|
+
}
|
|
1023
|
+
return out;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// packages/relay/src/cli.ts
|
|
1027
|
+
function defaultDbPath() {
|
|
1028
|
+
return join(homedir(), ".xacpx-relay", "relay.db");
|
|
1029
|
+
}
|
|
1030
|
+
function resolveBundledWebRoot() {
|
|
1031
|
+
const argv1 = process.argv[1];
|
|
1032
|
+
if (!argv1)
|
|
1033
|
+
return;
|
|
1034
|
+
if (!argv1.endsWith("cli.js"))
|
|
1035
|
+
return;
|
|
1036
|
+
const here = dirname2(argv1);
|
|
1037
|
+
const embedded = resolve(here, "relay-web");
|
|
1038
|
+
if (existsSync(join(embedded, "index.html")))
|
|
1039
|
+
return embedded;
|
|
1040
|
+
const sibling = resolve(here, "../../relay-web/dist");
|
|
1041
|
+
if (existsSync(join(sibling, "index.html")))
|
|
1042
|
+
return sibling;
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
var USAGE = [
|
|
1046
|
+
"Usage: xacpx-relay <command>",
|
|
1047
|
+
" start [--db <path>] [--web-root <dir>] [--host 0.0.0.0] [--http-port 8787] [--ws-port 8788] [--history-retention-days 30] [--request-timeout-ms 120000] [--trust-proxy]",
|
|
1048
|
+
" add token [--label <note>] [--db <path>]",
|
|
1049
|
+
" ls [--db <path>]",
|
|
1050
|
+
" rm token <value-or-id> [--db <path>]",
|
|
1051
|
+
"",
|
|
1052
|
+
" Defaults: --db ~/.xacpx-relay/relay.db --web-root auto-detects the bundled dashboard"
|
|
1053
|
+
].join(`
|
|
1054
|
+
`);
|
|
1055
|
+
function flag(args, name) {
|
|
1056
|
+
const index = args.indexOf(name);
|
|
1057
|
+
const value = index >= 0 ? args[index + 1] : undefined;
|
|
1058
|
+
return value && !value.startsWith("--") ? value : undefined;
|
|
1059
|
+
}
|
|
1060
|
+
function hasFlag(args, name) {
|
|
1061
|
+
return args.includes(name);
|
|
1062
|
+
}
|
|
1063
|
+
function parseStartOptions(args) {
|
|
1064
|
+
const dbPath = flag(args, "--db") ?? defaultDbPath();
|
|
1065
|
+
const retentionRaw = flag(args, "--history-retention-days");
|
|
1066
|
+
const retentionDays = retentionRaw !== undefined ? Number(retentionRaw) : undefined;
|
|
1067
|
+
const requestTimeoutRaw = flag(args, "--request-timeout-ms");
|
|
1068
|
+
const requestTimeoutMs = requestTimeoutRaw !== undefined ? Number(requestTimeoutRaw) : undefined;
|
|
1069
|
+
return {
|
|
1070
|
+
dbPath,
|
|
1071
|
+
httpPort: Number(flag(args, "--http-port") ?? "8787"),
|
|
1072
|
+
wsPort: Number(flag(args, "--ws-port") ?? "8788"),
|
|
1073
|
+
host: flag(args, "--host"),
|
|
1074
|
+
webRoot: flag(args, "--web-root"),
|
|
1075
|
+
historyRetentionDays: retentionDays !== undefined && !Number.isNaN(retentionDays) ? retentionDays : undefined,
|
|
1076
|
+
requestTimeoutMs: requestTimeoutMs !== undefined && !Number.isNaN(requestTimeoutMs) ? requestTimeoutMs : undefined,
|
|
1077
|
+
trustProxy: hasFlag(args, "--trust-proxy")
|
|
1078
|
+
};
|
|
1079
|
+
}
|
|
1080
|
+
async function runRelayCli(args, io) {
|
|
1081
|
+
const dbPath = flag(args, "--db") ?? defaultDbPath();
|
|
1082
|
+
if (args[0] === "start") {
|
|
1083
|
+
const startOpts = parseStartOptions(args);
|
|
1084
|
+
if (!startOpts.webRoot) {
|
|
1085
|
+
startOpts.webRoot = resolveBundledWebRoot();
|
|
1086
|
+
}
|
|
1087
|
+
const running = await startRelayServer(startOpts);
|
|
1088
|
+
io.print(`xacpx-relay listening: http :${running.httpPort}, instance ws :${running.wsPort}, db ${startOpts.dbPath}, dashboard: ${startOpts.webRoot ?? "(none)"}`);
|
|
1089
|
+
return await new Promise((resolve2) => {
|
|
1090
|
+
const shutdown = () => {
|
|
1091
|
+
running.close().then(() => resolve2(0));
|
|
1092
|
+
};
|
|
1093
|
+
process.once("SIGINT", shutdown);
|
|
1094
|
+
process.once("SIGTERM", shutdown);
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
if (args[0] === "add" && args[1] === "token") {
|
|
1098
|
+
const label = flag(args, "--label");
|
|
1099
|
+
const runtime = await createRelayRuntime(dbPath);
|
|
1100
|
+
try {
|
|
1101
|
+
const username = "u-" + randomUUID3();
|
|
1102
|
+
const acc = runtime.accounts.createAccount(username);
|
|
1103
|
+
const { token } = runtime.accounts.createLoginToken(acc.id, label);
|
|
1104
|
+
io.print(`access token: ${token}`);
|
|
1105
|
+
io.print("(store it now — not shown again)");
|
|
1106
|
+
io.print(`hint: use this token for web login AND: xacpx channel add relay --url ws://<host>:<ws-port> --token ${token}`);
|
|
1107
|
+
return 0;
|
|
1108
|
+
} finally {
|
|
1109
|
+
runtime.close();
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
if (args[0] === "ls") {
|
|
1113
|
+
const runtime = await createRelayRuntime(dbPath);
|
|
1114
|
+
try {
|
|
1115
|
+
const tokens = runtime.accounts.listTokens();
|
|
1116
|
+
if (tokens.length === 0) {
|
|
1117
|
+
io.print("(no tokens)");
|
|
1118
|
+
return 0;
|
|
1119
|
+
}
|
|
1120
|
+
io.print("id label created #instances");
|
|
1121
|
+
io.print("-------- -------------------- -------------------- ----------");
|
|
1122
|
+
for (const t of tokens) {
|
|
1123
|
+
const shortId = t.id.slice(0, 8);
|
|
1124
|
+
const label = (t.label ?? "").slice(0, 20).padEnd(20);
|
|
1125
|
+
const created = t.createdAt.slice(0, 19).replace("T", " ");
|
|
1126
|
+
io.print(`${shortId} ${label} ${created} ${String(t.instanceCount).padStart(10)}`);
|
|
1127
|
+
}
|
|
1128
|
+
return 0;
|
|
1129
|
+
} finally {
|
|
1130
|
+
runtime.close();
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
if (args[0] === "rm" && args[1] === "token") {
|
|
1134
|
+
const valueOrId = args[2];
|
|
1135
|
+
if (!valueOrId || valueOrId.startsWith("--")) {
|
|
1136
|
+
io.print(USAGE);
|
|
1137
|
+
return 1;
|
|
1138
|
+
}
|
|
1139
|
+
const runtime = await createRelayRuntime(dbPath);
|
|
1140
|
+
try {
|
|
1141
|
+
const accountId = runtime.accounts.accountIdForToken(valueOrId);
|
|
1142
|
+
if (!accountId) {
|
|
1143
|
+
io.print(`token not found: ${valueOrId}`);
|
|
1144
|
+
return 1;
|
|
1145
|
+
}
|
|
1146
|
+
runtime.accounts.deleteAccountCascade(accountId);
|
|
1147
|
+
io.print("removed");
|
|
1148
|
+
return 0;
|
|
1149
|
+
} finally {
|
|
1150
|
+
runtime.close();
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
io.print(USAGE);
|
|
1154
|
+
return 1;
|
|
1155
|
+
}
|
|
1156
|
+
var isMain = typeof process !== "undefined" && process.argv[1]?.endsWith("cli.js");
|
|
1157
|
+
if (isMain) {
|
|
1158
|
+
runRelayCli(process.argv.slice(2), { print: (line) => console.log(line) }).then((code) => process.exit(code), (error) => {
|
|
1159
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
1160
|
+
process.exit(1);
|
|
1161
|
+
});
|
|
1162
|
+
}
|
|
1163
|
+
export {
|
|
1164
|
+
runRelayCli,
|
|
1165
|
+
resolveBundledWebRoot,
|
|
1166
|
+
parseStartOptions,
|
|
1167
|
+
defaultDbPath
|
|
1168
|
+
};
|