khala-cli 0.3.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/LICENSE +201 -0
- package/README.md +33 -0
- package/dist/chunk-4C2TBZVA.mjs +245 -0
- package/dist/chunk-525XNG6V.mjs +110 -0
- package/dist/chunk-6FNXMYOM.mjs +8 -0
- package/dist/chunk-6GOETMWV.mjs +91 -0
- package/dist/chunk-CTXFYQIX.mjs +352 -0
- package/dist/chunk-FGIU3YO2.mjs +214 -0
- package/dist/chunk-GO5DD2TR.mjs +442 -0
- package/dist/chunk-H2JCKPFR.mjs +149 -0
- package/dist/chunk-HMGN52QY.mjs +11 -0
- package/dist/chunk-HWKLLI7F.mjs +1652 -0
- package/dist/chunk-MGXWUWWP.mjs +1294 -0
- package/dist/chunk-PQO6675G.mjs +135 -0
- package/dist/chunk-S6XASSNU.mjs +120 -0
- package/dist/chunk-UYTGV6TW.mjs +280 -0
- package/dist/chunk-Y3TDVOGT.mjs +11 -0
- package/dist/chunk-ZRUF3P53.mjs +557 -0
- package/dist/khala.mjs +89 -0
- package/dist/web/assets/Bungee-Regular-BC4W3tCU.woff2 +0 -0
- package/dist/web/assets/JetBrainsMono-Variable-CZ7Klmjc.woff2 +0 -0
- package/dist/web/assets/SpaceGrotesk-Variable-BWuzNAiS.woff2 +0 -0
- package/dist/web/assets/aiur-logo-Dx5KVuwr.png +0 -0
- package/dist/web/assets/local-DuGvSGji.css +1 -0
- package/dist/web/assets/local-hWRGqoWt.js +9 -0
- package/dist/web/index.html +14 -0
- package/package.json +35 -0
|
@@ -0,0 +1,1652 @@
|
|
|
1
|
+
import { createRequire as __khalaCreateRequire } from 'node:module'; const require = __khalaCreateRequire(import.meta.url);
|
|
2
|
+
import {
|
|
3
|
+
LOCAL_AGENT_DEVICE_PREFIX,
|
|
4
|
+
LOCAL_IDLE_EXIT_MS,
|
|
5
|
+
LOCAL_LINK_TTL_MS,
|
|
6
|
+
LOCAL_LONG_POLL_MAX_S,
|
|
7
|
+
LOCAL_OWNER_DEVICE_ID,
|
|
8
|
+
LOCAL_OWNER_ID,
|
|
9
|
+
LOCAL_OWNER_USER_ID,
|
|
10
|
+
LOCAL_TOKEN_BYTES,
|
|
11
|
+
base64url,
|
|
12
|
+
decodeChannelSecrets,
|
|
13
|
+
decodeLocalEvent,
|
|
14
|
+
decodeOwnerProfile,
|
|
15
|
+
ensureHelper,
|
|
16
|
+
helperPaths,
|
|
17
|
+
isLocalRoomId,
|
|
18
|
+
localRoomKey,
|
|
19
|
+
newLocalAgentUserId,
|
|
20
|
+
newLocalEventId,
|
|
21
|
+
newLocalRoomId,
|
|
22
|
+
readHelperFile
|
|
23
|
+
} from "./chunk-ZRUF3P53.mjs";
|
|
24
|
+
import {
|
|
25
|
+
parseChannelLink,
|
|
26
|
+
resolveLocalOwnerName
|
|
27
|
+
} from "./chunk-UYTGV6TW.mjs";
|
|
28
|
+
import {
|
|
29
|
+
KHALA_AGENT_VERSION
|
|
30
|
+
} from "./chunk-Y3TDVOGT.mjs";
|
|
31
|
+
import {
|
|
32
|
+
checkName,
|
|
33
|
+
defaultAgentName,
|
|
34
|
+
defaultHumanColor,
|
|
35
|
+
isDefaultAgentName,
|
|
36
|
+
isHumanColorId,
|
|
37
|
+
normalizeInitials
|
|
38
|
+
} from "./chunk-6GOETMWV.mjs";
|
|
39
|
+
import {
|
|
40
|
+
StateError,
|
|
41
|
+
ensureStateDir,
|
|
42
|
+
readJson,
|
|
43
|
+
writeJsonAtomic
|
|
44
|
+
} from "./chunk-S6XASSNU.mjs";
|
|
45
|
+
import {
|
|
46
|
+
CHANNEL_EVENT_TYPE,
|
|
47
|
+
DEFAULT_LISTENING_MODE,
|
|
48
|
+
LISTENING_MODE_COMMAND_TYPE,
|
|
49
|
+
LISTENING_MODE_MEMBER_KEY,
|
|
50
|
+
agentConfirmPagePath,
|
|
51
|
+
decodeChannelEvent,
|
|
52
|
+
decodeListeningMode,
|
|
53
|
+
decodeListeningModeCommand,
|
|
54
|
+
decodeWith,
|
|
55
|
+
encodeChannelEvent,
|
|
56
|
+
memberListeningMode,
|
|
57
|
+
object,
|
|
58
|
+
utf8Length
|
|
59
|
+
} from "./chunk-GO5DD2TR.mjs";
|
|
60
|
+
import {
|
|
61
|
+
bundle
|
|
62
|
+
} from "./chunk-HMGN52QY.mjs";
|
|
63
|
+
import "./chunk-6FNXMYOM.mjs";
|
|
64
|
+
|
|
65
|
+
// src/local/cli.ts
|
|
66
|
+
import { setTimeout as sleepDefault } from "node:timers/promises";
|
|
67
|
+
|
|
68
|
+
// src/local/serve.ts
|
|
69
|
+
import { createHash as createHash3, randomBytes as randomBytes2, timingSafeEqual as timingSafeEqual3 } from "node:crypto";
|
|
70
|
+
import { unlink } from "node:fs/promises";
|
|
71
|
+
import path3 from "node:path";
|
|
72
|
+
import { fileURLToPath } from "node:url";
|
|
73
|
+
|
|
74
|
+
// src/local/store.ts
|
|
75
|
+
import * as fs from "node:fs/promises";
|
|
76
|
+
import path from "node:path";
|
|
77
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
78
|
+
var LocalStoreError = class extends Error {
|
|
79
|
+
code;
|
|
80
|
+
constructor(code) {
|
|
81
|
+
super(code);
|
|
82
|
+
this.name = "LocalStoreError";
|
|
83
|
+
this.code = code;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
var sha256 = (token) => createHash("sha256").update(token).digest("hex");
|
|
87
|
+
var emptySecrets = () => ({ v: 1, links: {}, members: {} });
|
|
88
|
+
var lastSeq = (channel) => channel.events.at(-1)?.seq ?? 0;
|
|
89
|
+
var fire = (waiters) => {
|
|
90
|
+
for (const done of [...waiters]) done();
|
|
91
|
+
};
|
|
92
|
+
async function openLocalStore(input) {
|
|
93
|
+
const { root, now = Date.now, random = randomBytes } = input;
|
|
94
|
+
const channelsDir = path.join(root, "channels");
|
|
95
|
+
const channels = /* @__PURE__ */ new Map();
|
|
96
|
+
const operations = /* @__PURE__ */ new Map();
|
|
97
|
+
const agentChannel = /* @__PURE__ */ new Map();
|
|
98
|
+
const deletedAgentTokens = /* @__PURE__ */ new Map();
|
|
99
|
+
const revisionWaiters = /* @__PURE__ */ new Set();
|
|
100
|
+
let revision = 0;
|
|
101
|
+
let queue = Promise.resolve();
|
|
102
|
+
let closed = false;
|
|
103
|
+
let closing;
|
|
104
|
+
let owner2;
|
|
105
|
+
function makeChannel(roomId) {
|
|
106
|
+
return {
|
|
107
|
+
roomId,
|
|
108
|
+
dir: path.join(channelsDir, localRoomKey(roomId)),
|
|
109
|
+
name: "",
|
|
110
|
+
events: [],
|
|
111
|
+
byEventId: /* @__PURE__ */ new Map(),
|
|
112
|
+
byTxn: /* @__PURE__ */ new Map(),
|
|
113
|
+
lastMember: /* @__PURE__ */ new Map(),
|
|
114
|
+
secrets: emptySecrets(),
|
|
115
|
+
waiters: /* @__PURE__ */ new Set()
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
function indexEvent(channel, event) {
|
|
119
|
+
channel.events.push(event);
|
|
120
|
+
channel.byEventId.set(event.eventId, event);
|
|
121
|
+
if (event.txnId !== void 0) channel.byTxn.set(`${event.sender}\0${event.txnId}`, event);
|
|
122
|
+
if (event.type === "m.room.create") {
|
|
123
|
+
channel.name = event.content.name;
|
|
124
|
+
if (typeof event.content.operationId === "string") channel.operationId = event.content.operationId;
|
|
125
|
+
} else if (event.type === "m.room.name") channel.name = event.content.name;
|
|
126
|
+
else if (event.type === "m.room.member") {
|
|
127
|
+
const user = event.content.user;
|
|
128
|
+
const firstSeq = channel.lastMember.get(user)?.firstSeq ?? event.seq;
|
|
129
|
+
channel.lastMember.set(user, { event, firstSeq });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function register(channel) {
|
|
133
|
+
channels.set(channel.roomId, channel);
|
|
134
|
+
if (channel.operationId !== void 0) operations.set(channel.operationId, channel.roomId);
|
|
135
|
+
for (const user of channel.lastMember.keys()) {
|
|
136
|
+
if (user !== LOCAL_OWNER_USER_ID) agentChannel.set(user, channel.roomId);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
await ensureStateDir(channelsDir);
|
|
141
|
+
const decodedOwner = decodeOwnerProfile(await readJson(path.join(root, "owner.json")));
|
|
142
|
+
owner2 = structuredClone(decodedOwner.ok ? decodedOwner.value : input.ownerDefault);
|
|
143
|
+
for (const entry of await fs.readdir(channelsDir, { withFileTypes: true })) {
|
|
144
|
+
const roomId = `!${entry.name}:local`;
|
|
145
|
+
if (!isLocalRoomId(roomId) || !entry.isDirectory() && !entry.isSymbolicLink()) continue;
|
|
146
|
+
const channel = makeChannel(roomId);
|
|
147
|
+
await ensureStateDir(channel.dir);
|
|
148
|
+
let buffer;
|
|
149
|
+
try {
|
|
150
|
+
buffer = await fs.readFile(path.join(channel.dir, "log.jsonl"));
|
|
151
|
+
} catch (error2) {
|
|
152
|
+
if (error2.code === "ENOENT") continue;
|
|
153
|
+
throw error2;
|
|
154
|
+
}
|
|
155
|
+
const completeBytes = buffer.lastIndexOf(10) + 1;
|
|
156
|
+
if (completeBytes < buffer.length) channel.validBytes = completeBytes;
|
|
157
|
+
for (const line of buffer.subarray(0, completeBytes).toString("utf8").split("\n")) {
|
|
158
|
+
let parsed;
|
|
159
|
+
try {
|
|
160
|
+
parsed = JSON.parse(line);
|
|
161
|
+
} catch {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const decoded = decodeLocalEvent(parsed);
|
|
165
|
+
if (decoded.ok && decoded.value.roomId === roomId && decoded.value.seq > lastSeq(channel)) indexEvent(channel, decoded.value);
|
|
166
|
+
}
|
|
167
|
+
if (channel.events[0]?.type !== "m.room.create") continue;
|
|
168
|
+
const secrets = decodeChannelSecrets(await readJson(path.join(channel.dir, "secrets.json")));
|
|
169
|
+
channel.secrets = secrets.ok ? secrets.value : emptySecrets();
|
|
170
|
+
register(channel);
|
|
171
|
+
}
|
|
172
|
+
} catch (error2) {
|
|
173
|
+
if (error2 instanceof StateError && error2.code === "unsafe_state_dir") throw error2;
|
|
174
|
+
throw new LocalStoreError("storage_failed");
|
|
175
|
+
}
|
|
176
|
+
function enqueue(task) {
|
|
177
|
+
if (closed) return Promise.reject(new LocalStoreError("storage_failed"));
|
|
178
|
+
const run = queue.then(task).catch((error2) => {
|
|
179
|
+
if (error2 instanceof LocalStoreError) throw error2;
|
|
180
|
+
throw new LocalStoreError("storage_failed");
|
|
181
|
+
});
|
|
182
|
+
queue = run.catch(() => void 0);
|
|
183
|
+
return run;
|
|
184
|
+
}
|
|
185
|
+
function requireChannel(roomId) {
|
|
186
|
+
const channel = channels.get(roomId);
|
|
187
|
+
if (!channel) throw new LocalStoreError("not_found");
|
|
188
|
+
return channel;
|
|
189
|
+
}
|
|
190
|
+
function bump() {
|
|
191
|
+
revision++;
|
|
192
|
+
fire(revisionWaiters);
|
|
193
|
+
}
|
|
194
|
+
async function writeEvent(channel, eventInput) {
|
|
195
|
+
const event = {
|
|
196
|
+
seq: lastSeq(channel) + 1,
|
|
197
|
+
eventId: newLocalEventId(random(16)),
|
|
198
|
+
roomId: channel.roomId,
|
|
199
|
+
type: eventInput.type,
|
|
200
|
+
sender: eventInput.sender,
|
|
201
|
+
ts: now(),
|
|
202
|
+
...eventInput.txnId !== void 0 ? { txnId: eventInput.txnId } : {},
|
|
203
|
+
content: eventInput.content
|
|
204
|
+
};
|
|
205
|
+
const stored = structuredClone(event);
|
|
206
|
+
const logPath = path.join(channel.dir, "log.jsonl");
|
|
207
|
+
if (channel.validBytes !== void 0) {
|
|
208
|
+
await fs.truncate(logPath, channel.validBytes);
|
|
209
|
+
delete channel.validBytes;
|
|
210
|
+
}
|
|
211
|
+
channel.handle ??= await fs.open(logPath, "a", 384);
|
|
212
|
+
const start = (await channel.handle.stat()).size;
|
|
213
|
+
try {
|
|
214
|
+
await channel.handle.writeFile(JSON.stringify(stored) + "\n", "utf8");
|
|
215
|
+
} catch (error2) {
|
|
216
|
+
channel.validBytes = start;
|
|
217
|
+
throw error2;
|
|
218
|
+
}
|
|
219
|
+
indexEvent(channel, stored);
|
|
220
|
+
return stored;
|
|
221
|
+
}
|
|
222
|
+
function waitUntil(ready, waiters, timeoutMs, signal) {
|
|
223
|
+
if (ready() || signal.aborted || closed) return Promise.resolve();
|
|
224
|
+
return new Promise((resolve2) => {
|
|
225
|
+
const done = () => {
|
|
226
|
+
clearTimeout(timer);
|
|
227
|
+
signal.removeEventListener("abort", done);
|
|
228
|
+
waiters.delete(wake);
|
|
229
|
+
resolve2();
|
|
230
|
+
};
|
|
231
|
+
const wake = () => {
|
|
232
|
+
if (ready() || closed) done();
|
|
233
|
+
};
|
|
234
|
+
const timer = setTimeout(done, Math.max(0, timeoutMs));
|
|
235
|
+
signal.addEventListener("abort", done, { once: true });
|
|
236
|
+
waiters.add(wake);
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
function member(channel, userId) {
|
|
240
|
+
const event = channel.lastMember.get(userId)?.event;
|
|
241
|
+
if (!event) return void 0;
|
|
242
|
+
const content = event.content;
|
|
243
|
+
const human = userId === LOCAL_OWNER_USER_ID;
|
|
244
|
+
return {
|
|
245
|
+
userId,
|
|
246
|
+
participantId: userId,
|
|
247
|
+
ownerId: LOCAL_OWNER_ID,
|
|
248
|
+
deviceId: human ? LOCAL_OWNER_DEVICE_ID : LOCAL_AGENT_DEVICE_PREFIX + userId.slice(7, 15),
|
|
249
|
+
displayName: human ? owner2.username : content.displayname,
|
|
250
|
+
kind: content.kind,
|
|
251
|
+
membership: content.membership,
|
|
252
|
+
...!human ? {
|
|
253
|
+
ownerLabel: owner2.username,
|
|
254
|
+
listeningMode: memberListeningMode(content),
|
|
255
|
+
...content.harness !== void 0 ? { harness: content.harness } : {}
|
|
256
|
+
} : {}
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
function members(channel) {
|
|
260
|
+
const result = [];
|
|
261
|
+
const ordered = [...channel.lastMember.entries()].sort(([a, av], [b, bv]) => a === LOCAL_OWNER_USER_ID ? -1 : b === LOCAL_OWNER_USER_ID ? 1 : av.firstSeq - bv.firstSeq);
|
|
262
|
+
for (const [userId] of ordered) {
|
|
263
|
+
const value = member(channel, userId);
|
|
264
|
+
if (value && (value.membership === "invite" || value.membership === "join")) result.push({ ...value, membership: value.membership });
|
|
265
|
+
}
|
|
266
|
+
return result;
|
|
267
|
+
}
|
|
268
|
+
function summary(channel) {
|
|
269
|
+
let message;
|
|
270
|
+
for (let i = channel.events.length - 1; i >= 0; i--) {
|
|
271
|
+
const event = channel.events[i];
|
|
272
|
+
if (event.type === "m.room.message" && event.content.msgtype === "m.text" && typeof event.content.body === "string") {
|
|
273
|
+
message = event;
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return {
|
|
278
|
+
roomId: channel.roomId,
|
|
279
|
+
name: channel.name,
|
|
280
|
+
createdAt: new Date(channel.events[0].ts).toISOString(),
|
|
281
|
+
lastSeq: lastSeq(channel),
|
|
282
|
+
lastTs: channel.events.at(-1).ts,
|
|
283
|
+
preview: message ? message.content.body : null,
|
|
284
|
+
...message ? { lastSender: { userId: message.sender, displayName: member(channel, message.sender)?.displayName ?? message.sender } } : {},
|
|
285
|
+
members: members(channel).map((m) => ({
|
|
286
|
+
userId: m.userId,
|
|
287
|
+
displayName: m.displayName,
|
|
288
|
+
kind: m.kind,
|
|
289
|
+
...m.harness !== void 0 ? { harness: m.harness } : {}
|
|
290
|
+
}))
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
async function saveSecrets(channel, secrets) {
|
|
294
|
+
await writeJsonAtomic(path.join(channel.dir, "secrets.json"), secrets);
|
|
295
|
+
channel.secrets = secrets;
|
|
296
|
+
}
|
|
297
|
+
return {
|
|
298
|
+
listChannels: () => [...channels.values()].map(summary).sort((a, b) => b.lastTs - a.lastTs || b.lastSeq - a.lastSeq || (a.roomId < b.roomId ? -1 : a.roomId > b.roomId ? 1 : 0)),
|
|
299
|
+
channelSummary: (roomId) => {
|
|
300
|
+
const c = channels.get(roomId);
|
|
301
|
+
return c ? summary(c) : void 0;
|
|
302
|
+
},
|
|
303
|
+
createChannel: (name, operationId) => enqueue(async () => {
|
|
304
|
+
const existing = operationId !== void 0 ? operations.get(operationId) : void 0;
|
|
305
|
+
if (existing) return { roomId: existing, created: false };
|
|
306
|
+
let channel;
|
|
307
|
+
while (true) {
|
|
308
|
+
channel = makeChannel(newLocalRoomId(random(16)));
|
|
309
|
+
if (channels.has(channel.roomId)) continue;
|
|
310
|
+
try {
|
|
311
|
+
await fs.lstat(channel.dir);
|
|
312
|
+
} catch (error2) {
|
|
313
|
+
if (error2.code === "ENOENT") break;
|
|
314
|
+
throw error2;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
await ensureStateDir(channel.dir);
|
|
318
|
+
try {
|
|
319
|
+
await writeEvent(channel, {
|
|
320
|
+
type: "m.room.create",
|
|
321
|
+
sender: LOCAL_OWNER_USER_ID,
|
|
322
|
+
content: { name, createdBy: LOCAL_OWNER_USER_ID, ...operationId !== void 0 ? { operationId } : {} }
|
|
323
|
+
});
|
|
324
|
+
await writeEvent(channel, {
|
|
325
|
+
type: "m.room.member",
|
|
326
|
+
sender: LOCAL_OWNER_USER_ID,
|
|
327
|
+
content: { user: LOCAL_OWNER_USER_ID, membership: "join", displayname: owner2.username, kind: "human" }
|
|
328
|
+
});
|
|
329
|
+
await saveSecrets(channel, emptySecrets());
|
|
330
|
+
} catch (error2) {
|
|
331
|
+
await channel.handle?.close();
|
|
332
|
+
await fs.rm(channel.dir, { recursive: true, force: true });
|
|
333
|
+
throw error2;
|
|
334
|
+
}
|
|
335
|
+
register(channel);
|
|
336
|
+
bump();
|
|
337
|
+
bump();
|
|
338
|
+
return { roomId: channel.roomId, created: true };
|
|
339
|
+
}),
|
|
340
|
+
findByOperation: (operationId) => operations.get(operationId),
|
|
341
|
+
deleteChannel: (roomId) => enqueue(async () => {
|
|
342
|
+
const channel = channels.get(roomId);
|
|
343
|
+
if (!channel) return;
|
|
344
|
+
await channel.handle?.close();
|
|
345
|
+
delete channel.handle;
|
|
346
|
+
await fs.rm(channel.dir, { recursive: true, force: true });
|
|
347
|
+
for (const [userId, secret] of Object.entries(channel.secrets.members)) {
|
|
348
|
+
if (userId !== LOCAL_OWNER_USER_ID) deletedAgentTokens.set(secret.tokenSha256, { roomId, userId });
|
|
349
|
+
}
|
|
350
|
+
channels.delete(roomId);
|
|
351
|
+
if (channel.operationId !== void 0) operations.delete(channel.operationId);
|
|
352
|
+
for (const [user, room] of agentChannel) if (room === roomId) agentChannel.delete(user);
|
|
353
|
+
bump();
|
|
354
|
+
fire(channel.waiters);
|
|
355
|
+
}),
|
|
356
|
+
hasChannel: (roomId) => channels.has(roomId),
|
|
357
|
+
channelOfMember: (userId) => agentChannel.get(userId),
|
|
358
|
+
revision: () => revision,
|
|
359
|
+
waitForRevision: (since, timeoutMs, signal) => waitUntil(() => revision !== since, revisionWaiters, timeoutMs, signal),
|
|
360
|
+
append: (roomId, eventInput) => {
|
|
361
|
+
const ownedInput = structuredClone(eventInput);
|
|
362
|
+
return enqueue(async () => {
|
|
363
|
+
const channel = requireChannel(roomId);
|
|
364
|
+
const existing = ownedInput.txnId !== void 0 ? channel.byTxn.get(`${ownedInput.sender}\0${ownedInput.txnId}`) : void 0;
|
|
365
|
+
if (existing) return structuredClone(existing);
|
|
366
|
+
const event = await writeEvent(channel, ownedInput);
|
|
367
|
+
if (event.type === "m.room.member" && event.content.user !== LOCAL_OWNER_USER_ID) agentChannel.set(event.content.user, roomId);
|
|
368
|
+
bump();
|
|
369
|
+
fire(channel.waiters);
|
|
370
|
+
return structuredClone(event);
|
|
371
|
+
});
|
|
372
|
+
},
|
|
373
|
+
eventsAfter: (roomId, after, limit) => {
|
|
374
|
+
const events = channels.get(roomId)?.events ?? [];
|
|
375
|
+
let lo = 0;
|
|
376
|
+
let hi = events.length;
|
|
377
|
+
while (lo < hi) {
|
|
378
|
+
const mid = Math.floor((lo + hi) / 2);
|
|
379
|
+
if (events[mid].seq <= after) lo = mid + 1;
|
|
380
|
+
else hi = mid;
|
|
381
|
+
}
|
|
382
|
+
return structuredClone(events.slice(lo, lo + Math.max(0, limit)));
|
|
383
|
+
},
|
|
384
|
+
waitForEvent: (roomId, after, timeoutMs, signal) => {
|
|
385
|
+
const channel = channels.get(roomId);
|
|
386
|
+
return channel ? waitUntil(() => !channels.has(roomId) || lastSeq(channel) > after, channel.waiters, timeoutMs, signal) : Promise.resolve();
|
|
387
|
+
},
|
|
388
|
+
history: (roomId, before, limit) => {
|
|
389
|
+
const channel = channels.get(roomId);
|
|
390
|
+
const cutoff = before !== void 0 ? channel?.byEventId.get(before)?.seq : Infinity;
|
|
391
|
+
if (!channel || cutoff === void 0 || limit <= 0) return { events: [] };
|
|
392
|
+
const candidates = channel.events.filter((e) => e.seq < cutoff && (e.type === "m.room.message" || e.type === "com.khala.event.v1"));
|
|
393
|
+
const events = candidates.slice(-limit);
|
|
394
|
+
return { events: structuredClone(events), ...candidates.length > events.length ? { nextBefore: events[0].eventId } : {} };
|
|
395
|
+
},
|
|
396
|
+
members: (roomId) => {
|
|
397
|
+
const c = channels.get(roomId);
|
|
398
|
+
return c ? members(c) : [];
|
|
399
|
+
},
|
|
400
|
+
member: (roomId, userId) => {
|
|
401
|
+
const c = channels.get(roomId);
|
|
402
|
+
return c ? member(c, userId) : void 0;
|
|
403
|
+
},
|
|
404
|
+
channelName: (roomId) => channels.get(roomId)?.name ?? "",
|
|
405
|
+
mintLink: (roomId, kind) => enqueue(async () => {
|
|
406
|
+
const channel = requireChannel(roomId);
|
|
407
|
+
const token = base64url(random(LOCAL_TOKEN_BYTES));
|
|
408
|
+
const expiresAt = new Date(now() + LOCAL_LINK_TTL_MS).toISOString();
|
|
409
|
+
await saveSecrets(channel, { ...channel.secrets, links: { ...channel.secrets.links, [sha256(token)]: { expiresAt, kind } } });
|
|
410
|
+
return { token, expiresAt };
|
|
411
|
+
}),
|
|
412
|
+
consumeLink: (token) => enqueue(async () => {
|
|
413
|
+
if (!/^[A-Za-z0-9_-]{43}$/u.test(token)) return null;
|
|
414
|
+
const hash2 = sha256(token);
|
|
415
|
+
for (const channel of channels.values()) {
|
|
416
|
+
const link = channel.secrets.links[hash2];
|
|
417
|
+
if (!link || link.consumedAt !== void 0 || Date.parse(link.expiresAt) <= now()) continue;
|
|
418
|
+
const consumedAt = new Date(now()).toISOString();
|
|
419
|
+
await saveSecrets(channel, { ...channel.secrets, links: { ...channel.secrets.links, [hash2]: { ...link, consumedAt } } });
|
|
420
|
+
return { roomId: channel.roomId };
|
|
421
|
+
}
|
|
422
|
+
return null;
|
|
423
|
+
}),
|
|
424
|
+
setMemberToken: (roomId, userId, tokenSha256) => enqueue(async () => {
|
|
425
|
+
const channel = requireChannel(roomId);
|
|
426
|
+
const next = { ...channel.secrets.members };
|
|
427
|
+
if (tokenSha256 === null) delete next[userId];
|
|
428
|
+
else if (userId !== LOCAL_OWNER_USER_ID) next[userId] = { tokenSha256 };
|
|
429
|
+
await saveSecrets(channel, { ...channel.secrets, members: next });
|
|
430
|
+
}),
|
|
431
|
+
agentForToken: (token) => {
|
|
432
|
+
const hash2 = Buffer.from(sha256(token), "hex");
|
|
433
|
+
let result = null;
|
|
434
|
+
for (const [tokenSha256, agent] of deletedAgentTokens) {
|
|
435
|
+
const candidate = Buffer.from(tokenSha256, "hex");
|
|
436
|
+
if (candidate.length === hash2.length && timingSafeEqual(hash2, candidate)) result = { ...agent };
|
|
437
|
+
}
|
|
438
|
+
for (const channel of channels.values()) for (const [userId, secret] of Object.entries(channel.secrets.members)) {
|
|
439
|
+
const candidate = Buffer.from(secret.tokenSha256, "hex");
|
|
440
|
+
if (candidate.length === hash2.length && timingSafeEqual(hash2, candidate) && userId !== LOCAL_OWNER_USER_ID) result = { roomId: channel.roomId, userId };
|
|
441
|
+
}
|
|
442
|
+
return result;
|
|
443
|
+
},
|
|
444
|
+
owner: () => structuredClone(owner2),
|
|
445
|
+
setOwner: (next) => {
|
|
446
|
+
const owned = structuredClone(next);
|
|
447
|
+
return enqueue(async () => {
|
|
448
|
+
await writeJsonAtomic(path.join(root, "owner.json"), owned);
|
|
449
|
+
owner2 = owned;
|
|
450
|
+
bump();
|
|
451
|
+
});
|
|
452
|
+
},
|
|
453
|
+
close: () => {
|
|
454
|
+
if (!closing) {
|
|
455
|
+
closed = true;
|
|
456
|
+
closing = (async () => {
|
|
457
|
+
await queue;
|
|
458
|
+
try {
|
|
459
|
+
await Promise.all([...channels.values()].map((c) => c.handle?.close()));
|
|
460
|
+
} finally {
|
|
461
|
+
fire(revisionWaiters);
|
|
462
|
+
for (const channel of channels.values()) fire(channel.waiters);
|
|
463
|
+
}
|
|
464
|
+
})();
|
|
465
|
+
}
|
|
466
|
+
return closing;
|
|
467
|
+
}
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// src/local/http.ts
|
|
472
|
+
import { createServer } from "node:http";
|
|
473
|
+
import * as fs2 from "node:fs/promises";
|
|
474
|
+
import path2 from "node:path";
|
|
475
|
+
var LOCAL_OWNER_COOKIE = "khala_local_owner";
|
|
476
|
+
var LOCAL_BODY_LIMIT_BYTES = 65536;
|
|
477
|
+
var LOCAL_CSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; object-src 'none'";
|
|
478
|
+
var mime = {
|
|
479
|
+
".html": "text/html; charset=utf-8",
|
|
480
|
+
".js": "text/javascript; charset=utf-8",
|
|
481
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
482
|
+
".css": "text/css; charset=utf-8",
|
|
483
|
+
".json": "application/json; charset=utf-8",
|
|
484
|
+
".svg": "image/svg+xml",
|
|
485
|
+
".png": "image/png",
|
|
486
|
+
".ico": "image/x-icon",
|
|
487
|
+
".woff2": "font/woff2",
|
|
488
|
+
".txt": "text/plain; charset=utf-8"
|
|
489
|
+
};
|
|
490
|
+
function respond(res, result) {
|
|
491
|
+
if (res.destroyed || res.writableEnded) return;
|
|
492
|
+
for (const [key, value] of Object.entries(result.headers ?? {})) {
|
|
493
|
+
if (!key.toLowerCase().startsWith("access-control-")) res.setHeader(key, value);
|
|
494
|
+
}
|
|
495
|
+
res.setHeader("cache-control", "no-store");
|
|
496
|
+
res.setHeader("x-content-type-options", "nosniff");
|
|
497
|
+
if ("location" in result) {
|
|
498
|
+
res.statusCode = 302;
|
|
499
|
+
res.setHeader("location", result.location);
|
|
500
|
+
res.end();
|
|
501
|
+
} else {
|
|
502
|
+
const body = result.json === void 0 ? void 0 : JSON.stringify(result.json);
|
|
503
|
+
res.statusCode = result.status;
|
|
504
|
+
if (body !== void 0) res.setHeader("content-type", "application/json; charset=utf-8");
|
|
505
|
+
res.end(body);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
function error(res, status, code) {
|
|
509
|
+
respond(res, { status, json: { error: code } });
|
|
510
|
+
}
|
|
511
|
+
function readBody(req, signal) {
|
|
512
|
+
return new Promise((resolve2, reject) => {
|
|
513
|
+
const chunks = [];
|
|
514
|
+
let size = 0;
|
|
515
|
+
const cleanup = () => {
|
|
516
|
+
req.off("data", data);
|
|
517
|
+
req.off("end", end);
|
|
518
|
+
req.off("error", fail5);
|
|
519
|
+
signal.removeEventListener("abort", abort);
|
|
520
|
+
};
|
|
521
|
+
const fail5 = (err) => {
|
|
522
|
+
cleanup();
|
|
523
|
+
reject(err);
|
|
524
|
+
};
|
|
525
|
+
const abort = () => fail5(new Error("client_disconnected"));
|
|
526
|
+
const data = (chunk) => {
|
|
527
|
+
size += chunk.length;
|
|
528
|
+
if (size > LOCAL_BODY_LIMIT_BYTES) {
|
|
529
|
+
cleanup();
|
|
530
|
+
chunks.length = 0;
|
|
531
|
+
req.resume();
|
|
532
|
+
reject(new Error("payload_too_large"));
|
|
533
|
+
} else chunks.push(chunk);
|
|
534
|
+
};
|
|
535
|
+
const end = () => {
|
|
536
|
+
cleanup();
|
|
537
|
+
try {
|
|
538
|
+
resolve2(size === 0 ? void 0 : JSON.parse(Buffer.concat(chunks).toString("utf8")));
|
|
539
|
+
} catch {
|
|
540
|
+
reject(new Error("invalid_request"));
|
|
541
|
+
}
|
|
542
|
+
};
|
|
543
|
+
req.on("data", data);
|
|
544
|
+
req.on("end", end);
|
|
545
|
+
req.on("error", fail5);
|
|
546
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
547
|
+
if (signal.aborted) abort();
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
var inside = (root, file) => file === root || file.startsWith(root + path2.sep);
|
|
551
|
+
function createHelperServer(options) {
|
|
552
|
+
let port = 0;
|
|
553
|
+
let origin = "";
|
|
554
|
+
let ctx;
|
|
555
|
+
let inFlight = 0;
|
|
556
|
+
let timer;
|
|
557
|
+
let closing = false;
|
|
558
|
+
let closePromise;
|
|
559
|
+
let listenPromise;
|
|
560
|
+
let cancelListen;
|
|
561
|
+
const controllers = /* @__PURE__ */ new Set();
|
|
562
|
+
const webDir = path2.resolve(options.webDir);
|
|
563
|
+
const log = options.log ?? ((line) => {
|
|
564
|
+
process.stderr.write(line + "\n");
|
|
565
|
+
});
|
|
566
|
+
const armIdle = () => {
|
|
567
|
+
clearTimeout(timer);
|
|
568
|
+
if (!closing) timer = setTimeout(() => {
|
|
569
|
+
if (inFlight === 0 && !closing) options.onIdle();
|
|
570
|
+
}, options.idleMs);
|
|
571
|
+
};
|
|
572
|
+
const server = createServer((req, res) => {
|
|
573
|
+
inFlight++;
|
|
574
|
+
clearTimeout(timer);
|
|
575
|
+
const controller = new AbortController();
|
|
576
|
+
controllers.add(controller);
|
|
577
|
+
res.once("close", () => {
|
|
578
|
+
if (!res.writableEnded) controller.abort();
|
|
579
|
+
controllers.delete(controller);
|
|
580
|
+
inFlight--;
|
|
581
|
+
if (inFlight === 0) armIdle();
|
|
582
|
+
});
|
|
583
|
+
void handle(req, res, controller.signal);
|
|
584
|
+
});
|
|
585
|
+
async function serveStatic(encoded, res) {
|
|
586
|
+
const index = path2.join(webDir, "index.html");
|
|
587
|
+
try {
|
|
588
|
+
if (!(await fs2.stat(index)).isFile()) return error(res, 503, "web_not_built");
|
|
589
|
+
} catch {
|
|
590
|
+
return error(res, 503, "web_not_built");
|
|
591
|
+
}
|
|
592
|
+
let decoded;
|
|
593
|
+
try {
|
|
594
|
+
decoded = decodeURIComponent(encoded);
|
|
595
|
+
} catch {
|
|
596
|
+
return error(res, 400, "invalid_request");
|
|
597
|
+
}
|
|
598
|
+
let file = path2.join(webDir, decoded);
|
|
599
|
+
if (decoded.includes("\0") || !inside(webDir, file)) return error(res, 404, "not_found");
|
|
600
|
+
const root = await fs2.realpath(webDir);
|
|
601
|
+
let regular = false;
|
|
602
|
+
try {
|
|
603
|
+
if ((await fs2.stat(file)).isFile()) {
|
|
604
|
+
file = await fs2.realpath(file);
|
|
605
|
+
if (!inside(root, file)) return error(res, 404, "not_found");
|
|
606
|
+
regular = true;
|
|
607
|
+
}
|
|
608
|
+
} catch {
|
|
609
|
+
}
|
|
610
|
+
if (!regular) {
|
|
611
|
+
if (decoded.startsWith("/assets/")) return error(res, 404, "not_found");
|
|
612
|
+
file = await fs2.realpath(index);
|
|
613
|
+
if (!inside(root, file)) return error(res, 404, "not_found");
|
|
614
|
+
}
|
|
615
|
+
const bytes = await fs2.readFile(file);
|
|
616
|
+
if (res.destroyed) return;
|
|
617
|
+
const extension = path2.extname(file).toLowerCase();
|
|
618
|
+
res.statusCode = 200;
|
|
619
|
+
res.setHeader("content-type", mime[extension] ?? "application/octet-stream");
|
|
620
|
+
res.setHeader("cache-control", "no-cache");
|
|
621
|
+
res.setHeader("x-content-type-options", "nosniff");
|
|
622
|
+
if (extension === ".html") {
|
|
623
|
+
res.setHeader("content-security-policy", LOCAL_CSP);
|
|
624
|
+
res.setHeader("referrer-policy", "no-referrer");
|
|
625
|
+
}
|
|
626
|
+
res.end(bytes);
|
|
627
|
+
}
|
|
628
|
+
async function handle(req, res, signal) {
|
|
629
|
+
let routeIndex = -1;
|
|
630
|
+
try {
|
|
631
|
+
const host = req.headers.host?.toLowerCase();
|
|
632
|
+
if (host !== `127.0.0.1:${port}` && host !== `localhost:${port}`) return error(res, 421, "misdirected");
|
|
633
|
+
const method = req.method;
|
|
634
|
+
if (method !== "GET" && method !== "POST" && method !== "PUT" && method !== "DELETE") return error(res, 405, "method_not_allowed");
|
|
635
|
+
let url;
|
|
636
|
+
try {
|
|
637
|
+
url = new URL(req.url, origin);
|
|
638
|
+
} catch {
|
|
639
|
+
return error(res, 400, "invalid_request");
|
|
640
|
+
}
|
|
641
|
+
const pathname = url.pathname;
|
|
642
|
+
if (pathname === "/healthz") return method === "GET" ? respond(res, { status: 200, json: { ok: true, version: ctx.version, pid: process.pid } }) : error(res, 405, "method_not_allowed");
|
|
643
|
+
let auth = { kind: "none" };
|
|
644
|
+
const authorization = req.headers.authorization;
|
|
645
|
+
if (authorization !== void 0) {
|
|
646
|
+
const bearer = /^Bearer ([A-Za-z0-9_-]{16,256})$/.exec(authorization);
|
|
647
|
+
if (bearer) auth = options.authenticateBearer(bearer[1]);
|
|
648
|
+
} else {
|
|
649
|
+
const value = req.headers.cookie?.split(";").map((piece) => piece.trim()).find((piece) => piece.startsWith(LOCAL_OWNER_COOKIE + "="))?.slice(LOCAL_OWNER_COOKIE.length + 1);
|
|
650
|
+
if (value && /^[A-Za-z0-9_-]{43}$/.test(value) && options.authenticateCookie(value)) auth = { kind: "owner", via: "cookie" };
|
|
651
|
+
}
|
|
652
|
+
if (authorization === void 0 && method !== "GET" && auth.kind === "owner" && auth.via === "cookie" && (req.headers["x-khala-local"] !== "1" || req.headers.origin !== void 0 && req.headers.origin !== origin)) return error(res, 403, "forbidden_origin");
|
|
653
|
+
if (!pathname.startsWith("/api/") && !pathname.startsWith("/open/")) return method === "GET" ? await serveStatic(pathname, res) : error(res, 405, "method_not_allowed");
|
|
654
|
+
let body;
|
|
655
|
+
if (method === "POST" || method === "PUT") {
|
|
656
|
+
if (Number(req.headers["content-length"]) > LOCAL_BODY_LIMIT_BYTES) {
|
|
657
|
+
res.setHeader("connection", "close");
|
|
658
|
+
req.resume();
|
|
659
|
+
return error(res, 413, "payload_too_large");
|
|
660
|
+
}
|
|
661
|
+
try {
|
|
662
|
+
body = await readBody(req, signal);
|
|
663
|
+
} catch (err) {
|
|
664
|
+
if (signal.aborted) return;
|
|
665
|
+
if (err instanceof Error && err.message === "payload_too_large") {
|
|
666
|
+
res.setHeader("connection", "close");
|
|
667
|
+
return error(res, 413, "payload_too_large");
|
|
668
|
+
}
|
|
669
|
+
return error(res, 400, "invalid_request");
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
let matched = false;
|
|
673
|
+
for (let i = 0; i < options.routes.length; i++) {
|
|
674
|
+
const route = options.routes[i];
|
|
675
|
+
route.pattern.lastIndex = 0;
|
|
676
|
+
const match = route.pattern.exec(pathname);
|
|
677
|
+
route.pattern.lastIndex = 0;
|
|
678
|
+
if (!match) continue;
|
|
679
|
+
matched = true;
|
|
680
|
+
if (route.method !== method) continue;
|
|
681
|
+
let params;
|
|
682
|
+
try {
|
|
683
|
+
params = match.slice(1).map(decodeURIComponent);
|
|
684
|
+
} catch {
|
|
685
|
+
return error(res, 400, "invalid_request");
|
|
686
|
+
}
|
|
687
|
+
routeIndex = i;
|
|
688
|
+
respond(res, await route.handle({ method, path: pathname, query: url.searchParams, headers: req.headers, body, auth, origin, signal }, params, ctx));
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
error(res, matched ? 405 : 404, matched ? "method_not_allowed" : "not_found");
|
|
692
|
+
} catch (err) {
|
|
693
|
+
const reason = err instanceof Error ? err.name : "unknown_error";
|
|
694
|
+
log(JSON.stringify({ at: "route", method: req.method, route: routeIndex, reason }));
|
|
695
|
+
if (!res.headersSent) error(res, 500, "internal_error");
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
return {
|
|
699
|
+
listen() {
|
|
700
|
+
if (closing) return Promise.reject(new Error("server_closed"));
|
|
701
|
+
return listenPromise ??= new Promise((resolve2, reject) => {
|
|
702
|
+
const fail5 = (err) => {
|
|
703
|
+
cancelListen = void 0;
|
|
704
|
+
reject(err);
|
|
705
|
+
};
|
|
706
|
+
cancelListen = () => {
|
|
707
|
+
server.off("error", fail5);
|
|
708
|
+
reject(new Error("server_closed"));
|
|
709
|
+
};
|
|
710
|
+
server.once("error", fail5);
|
|
711
|
+
server.listen({ host: "127.0.0.1", port: options.port }, () => {
|
|
712
|
+
server.off("error", fail5);
|
|
713
|
+
cancelListen = void 0;
|
|
714
|
+
const address = server.address();
|
|
715
|
+
if (!address || typeof address === "string") return reject(new Error("invalid_address"));
|
|
716
|
+
port = address.port;
|
|
717
|
+
origin = `http://127.0.0.1:${port}`;
|
|
718
|
+
try {
|
|
719
|
+
ctx = options.createContext(origin);
|
|
720
|
+
armIdle();
|
|
721
|
+
resolve2({ port, origin });
|
|
722
|
+
} catch (err) {
|
|
723
|
+
void server.close();
|
|
724
|
+
reject(err);
|
|
725
|
+
}
|
|
726
|
+
});
|
|
727
|
+
});
|
|
728
|
+
},
|
|
729
|
+
close() {
|
|
730
|
+
if (closePromise) return closePromise;
|
|
731
|
+
closing = true;
|
|
732
|
+
clearTimeout(timer);
|
|
733
|
+
cancelListen?.();
|
|
734
|
+
cancelListen = void 0;
|
|
735
|
+
for (const controller of controllers) controller.abort();
|
|
736
|
+
closePromise = new Promise((resolve2) => {
|
|
737
|
+
server.close(() => resolve2());
|
|
738
|
+
server.closeAllConnections();
|
|
739
|
+
});
|
|
740
|
+
return closePromise;
|
|
741
|
+
}
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// src/local/routes/agent-join.ts
|
|
746
|
+
import { createHash as createHash2, timingSafeEqual as timingSafeEqual2 } from "node:crypto";
|
|
747
|
+
var sha256hex = (value) => createHash2("sha256").update(value).digest("hex");
|
|
748
|
+
var b64 = (bytes) => Buffer.from(bytes).toString("base64url");
|
|
749
|
+
var fail = (status, error2) => ({ status, json: { error: error2 } });
|
|
750
|
+
function safeEqual(a, b) {
|
|
751
|
+
const left = Buffer.from(a), right = Buffer.from(b);
|
|
752
|
+
return left.length === right.length && timingSafeEqual2(left, right);
|
|
753
|
+
}
|
|
754
|
+
function serial() {
|
|
755
|
+
let tail = Promise.resolve();
|
|
756
|
+
return (fn) => {
|
|
757
|
+
const run = tail.then(fn, fn);
|
|
758
|
+
tail = run.catch(() => void 0);
|
|
759
|
+
return run;
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
function prune(ctx) {
|
|
763
|
+
const now = ctx.now();
|
|
764
|
+
for (const [id, entry] of ctx.joins) if (now >= entry.expiresAt + LOCAL_LINK_TTL_MS) ctx.joins.delete(id);
|
|
765
|
+
}
|
|
766
|
+
function authenticate(req, ctx) {
|
|
767
|
+
const entries = [...req.query];
|
|
768
|
+
if (entries.length !== 1 || entries[0][0] !== "joinId" || !/^[A-Za-z0-9_-]{22}$/u.test(entries[0][1])) return null;
|
|
769
|
+
const header = req.headers.authorization;
|
|
770
|
+
const bearer = /^Bearer ([^\s]+)$/u.exec((Array.isArray(header) ? header[0] : header) ?? "");
|
|
771
|
+
const entry = ctx.joins.get(entries[0][1]);
|
|
772
|
+
return bearer && entry && safeEqual(sha256hex(bearer[1]), entry.pollSecretSha256) ? entry : null;
|
|
773
|
+
}
|
|
774
|
+
function agentJoinRoutes() {
|
|
775
|
+
const queue = serial();
|
|
776
|
+
async function create(req, _params, ctx) {
|
|
777
|
+
try {
|
|
778
|
+
prune(ctx);
|
|
779
|
+
if (typeof req.body !== "object" || req.body === null || Array.isArray(req.body)) return fail(400, "invalid_link");
|
|
780
|
+
const body = req.body;
|
|
781
|
+
if (!Object.hasOwn(body, "link") || Object.keys(body).some((key) => !["link", "harness", "label"].includes(key))) return fail(400, "invalid_link");
|
|
782
|
+
if (typeof body.link !== "string" || !parseChannelLink(body.link)) return fail(400, "invalid_link");
|
|
783
|
+
const harness = body.harness;
|
|
784
|
+
if (harness !== "claude" && harness !== "codex") return fail(400, "invalid_harness");
|
|
785
|
+
const url = new URL(body.link);
|
|
786
|
+
if (url.origin !== ctx.origin && url.origin !== ctx.origin.replace("://127.0.0.1:", "://localhost:")) return fail(404, "link_unavailable");
|
|
787
|
+
const token = url.pathname.slice("/join/".length);
|
|
788
|
+
if (!/^[A-Za-z0-9_-]{43}$/u.test(token)) return fail(404, "link_unavailable");
|
|
789
|
+
const username = ctx.store.owner().username;
|
|
790
|
+
if (!checkName(defaultAgentName(username, harness, 2), "agent").ok) return fail(503, "unavailable");
|
|
791
|
+
return await queue(async () => {
|
|
792
|
+
const link = await ctx.store.consumeLink(token);
|
|
793
|
+
if (!link || !ctx.store.hasChannel(link.roomId)) return fail(404, "link_unavailable");
|
|
794
|
+
const roomId = link.roomId;
|
|
795
|
+
const taken = new Set(ctx.store.members(roomId).map((member) => member.displayName.toLowerCase()));
|
|
796
|
+
let n = 1;
|
|
797
|
+
while (taken.has(defaultAgentName(username, harness, n).toLowerCase())) n++;
|
|
798
|
+
const checked = checkName(defaultAgentName(username, harness, n), "agent");
|
|
799
|
+
if (!checked.ok) return fail(503, "unavailable");
|
|
800
|
+
let userId = newLocalAgentUserId(ctx.random(4));
|
|
801
|
+
for (let attempt = 0; attempt < 5 && ctx.store.channelOfMember(userId) !== void 0; attempt++) userId = newLocalAgentUserId(ctx.random(4));
|
|
802
|
+
if (ctx.store.channelOfMember(userId) !== void 0) return fail(503, "unavailable");
|
|
803
|
+
const deviceId = "KH_LOCAL_" + userId.slice("@agent-".length, -":local".length);
|
|
804
|
+
const accessToken = b64(ctx.random(LOCAL_TOKEN_BYTES));
|
|
805
|
+
await ctx.store.append(roomId, {
|
|
806
|
+
type: "m.room.member",
|
|
807
|
+
sender: LOCAL_OWNER_USER_ID,
|
|
808
|
+
content: { user: userId, membership: "invite", displayname: checked.name, kind: "agent", harness, invitedBy: LOCAL_OWNER_USER_ID }
|
|
809
|
+
});
|
|
810
|
+
await ctx.store.setMemberToken(roomId, userId, sha256hex(accessToken));
|
|
811
|
+
const joinId = b64(ctx.random(16)), pollSecret = b64(ctx.random(LOCAL_TOKEN_BYTES));
|
|
812
|
+
const expiresAt = ctx.now() + LOCAL_LINK_TTL_MS;
|
|
813
|
+
const credentials = { homeserver: ctx.origin, userId, accessToken, deviceId, roomId, transport: "local" };
|
|
814
|
+
ctx.joins.set(joinId, { joinId, pollSecretSha256: sha256hex(pollSecret), roomId, credentials, state: "confirmed", expiresAt });
|
|
815
|
+
const result = {
|
|
816
|
+
joinId,
|
|
817
|
+
pollSecret,
|
|
818
|
+
confirmUrl: url.origin + agentConfirmPagePath(joinId),
|
|
819
|
+
expiresAt: new Date(expiresAt).toISOString(),
|
|
820
|
+
autoConfirmed: true
|
|
821
|
+
};
|
|
822
|
+
return { status: 201, json: result };
|
|
823
|
+
});
|
|
824
|
+
} catch {
|
|
825
|
+
return fail(503, "unavailable");
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
async function poll(req, _params, ctx) {
|
|
829
|
+
prune(ctx);
|
|
830
|
+
const entry = authenticate(req, ctx);
|
|
831
|
+
if (!entry) return fail(404, "not_found");
|
|
832
|
+
if (entry.state === "confirmed" && ctx.now() >= entry.expiresAt) {
|
|
833
|
+
ctx.joins.delete(entry.joinId);
|
|
834
|
+
return { status: 200, json: { state: "expired" } };
|
|
835
|
+
}
|
|
836
|
+
if (entry.state !== "confirmed") return { status: 200, json: { state: "claimed" } };
|
|
837
|
+
ctx.joins.set(entry.joinId, { ...entry, state: "claimed", credentials: { ...entry.credentials, accessToken: "" } });
|
|
838
|
+
return { status: 200, json: { state: "confirmed", credentials: entry.credentials } };
|
|
839
|
+
}
|
|
840
|
+
async function ready(req, _params, ctx) {
|
|
841
|
+
prune(ctx);
|
|
842
|
+
const entry = authenticate(req, ctx);
|
|
843
|
+
if (!entry) return fail(404, "not_found");
|
|
844
|
+
if (entry.state === "confirmed") return fail(409, "not_confirmed");
|
|
845
|
+
entry.state = "ready";
|
|
846
|
+
return { status: 204 };
|
|
847
|
+
}
|
|
848
|
+
return [
|
|
849
|
+
{ method: "POST", pattern: /^\/api\/agent\/join$/u, handle: create },
|
|
850
|
+
{ method: "GET", pattern: /^\/api\/agent\/join\/poll$/u, handle: poll },
|
|
851
|
+
{ method: "POST", pattern: /^\/api\/agent\/join\/ready$/u, handle: ready }
|
|
852
|
+
];
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// src/local/routes/rooms.ts
|
|
856
|
+
var fail2 = (status, error2) => ({ status, json: { error: error2 } });
|
|
857
|
+
function serial2() {
|
|
858
|
+
let tail = Promise.resolve();
|
|
859
|
+
return (fn) => {
|
|
860
|
+
const next = tail.then(fn, fn);
|
|
861
|
+
tail = next.catch(() => void 0);
|
|
862
|
+
return next;
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
function isPlainObject(value) {
|
|
866
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
|
|
867
|
+
}
|
|
868
|
+
function resolve(req, params, ctx, need) {
|
|
869
|
+
if (req.auth.kind === "none") return fail2(401, "unauthorized");
|
|
870
|
+
const userId = req.auth.kind === "owner" ? LOCAL_OWNER_USER_ID : req.auth.userId;
|
|
871
|
+
let roomId;
|
|
872
|
+
try {
|
|
873
|
+
roomId = decodeURIComponent(params[0]);
|
|
874
|
+
} catch {
|
|
875
|
+
return fail2(404, "not_found");
|
|
876
|
+
}
|
|
877
|
+
if (!ctx.store.hasChannel(roomId)) return fail2(404, "not_found");
|
|
878
|
+
if (req.auth.kind === "agent" && req.auth.roomId !== roomId) return fail2(403, "not_member");
|
|
879
|
+
const member = ctx.store.member(roomId, userId);
|
|
880
|
+
if (!member || need === "present" && member.membership === "leave" || need === "joined" && member.membership !== "join") return fail2(403, "not_member");
|
|
881
|
+
return { userId, kind: req.auth.kind, roomId, member };
|
|
882
|
+
}
|
|
883
|
+
var announcementTxn = (event) => `local.join.${event.eventId.slice(1)}`;
|
|
884
|
+
function memberEvents(store, roomId, userId) {
|
|
885
|
+
let after = 0;
|
|
886
|
+
let current;
|
|
887
|
+
let joined;
|
|
888
|
+
let announced = false;
|
|
889
|
+
for (; ; ) {
|
|
890
|
+
const page = store.eventsAfter(roomId, after, 200);
|
|
891
|
+
for (const event of page) {
|
|
892
|
+
if (event.type === "m.room.member" && event.content["user"] === userId) {
|
|
893
|
+
if (event.content["membership"] !== "join") {
|
|
894
|
+
joined = void 0;
|
|
895
|
+
announced = false;
|
|
896
|
+
} else if (current?.content["membership"] !== "join") {
|
|
897
|
+
joined = event;
|
|
898
|
+
announced = false;
|
|
899
|
+
}
|
|
900
|
+
current = event;
|
|
901
|
+
} else if (joined && event.type === "com.khala.event.v1" && event.sender === LOCAL_OWNER_USER_ID && event.txnId === announcementTxn(joined)) {
|
|
902
|
+
announced = true;
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
if (page.length < 200) return { current, joined, announced };
|
|
906
|
+
after = page.at(-1).seq;
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
function memberContent(member, extra = {}) {
|
|
910
|
+
return {
|
|
911
|
+
user: member.userId,
|
|
912
|
+
membership: "join",
|
|
913
|
+
displayname: member.displayName,
|
|
914
|
+
kind: member.kind,
|
|
915
|
+
...member.harness ? { harness: member.harness } : {},
|
|
916
|
+
...member.kind === "agent" ? { [LISTENING_MODE_MEMBER_KEY]: member.listeningMode ?? DEFAULT_LISTENING_MODE } : {},
|
|
917
|
+
...extra
|
|
918
|
+
};
|
|
919
|
+
}
|
|
920
|
+
function roomRoutes() {
|
|
921
|
+
const enqueue = serial2();
|
|
922
|
+
const route = (method, pattern, need, handle) => ({
|
|
923
|
+
method,
|
|
924
|
+
pattern,
|
|
925
|
+
async handle(req, params, ctx) {
|
|
926
|
+
try {
|
|
927
|
+
const caller = resolve(req, params, ctx, need);
|
|
928
|
+
return "status" in caller ? caller : await handle(req, params, ctx, caller);
|
|
929
|
+
} catch {
|
|
930
|
+
return fail2(503, "unavailable");
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
});
|
|
934
|
+
return [
|
|
935
|
+
route("GET", /^\/api\/local\/rooms\/([^/]+)\/me$/u, "any", async (_req, _params, ctx, caller) => ({
|
|
936
|
+
status: 200,
|
|
937
|
+
json: {
|
|
938
|
+
userId: caller.userId,
|
|
939
|
+
roomId: caller.roomId,
|
|
940
|
+
roomName: ctx.store.channelName(caller.roomId),
|
|
941
|
+
membership: caller.member.membership,
|
|
942
|
+
displayName: caller.member.displayName,
|
|
943
|
+
...caller.kind === "agent" ? { invitedBy: LOCAL_OWNER_USER_ID } : {}
|
|
944
|
+
}
|
|
945
|
+
})),
|
|
946
|
+
route("POST", /^\/api\/local\/rooms\/([^/]+)\/join$/u, "present", async (req, params, ctx) => {
|
|
947
|
+
if (req.body !== void 0 && (!isPlainObject(req.body) || Object.keys(req.body).length > 0)) return fail2(400, "invalid_request");
|
|
948
|
+
return enqueue(async () => {
|
|
949
|
+
const caller = resolve(req, params, ctx, "present");
|
|
950
|
+
if ("status" in caller) return caller;
|
|
951
|
+
let event;
|
|
952
|
+
let joined;
|
|
953
|
+
let announced = false;
|
|
954
|
+
if (caller.member.membership === "join") {
|
|
955
|
+
({ current: event, joined, announced } = memberEvents(ctx.store, caller.roomId, caller.userId));
|
|
956
|
+
} else {
|
|
957
|
+
event = await ctx.store.append(caller.roomId, { type: "m.room.member", sender: caller.userId, content: memberContent(caller.member) });
|
|
958
|
+
joined = event;
|
|
959
|
+
}
|
|
960
|
+
if (caller.kind === "agent" && joined && !announced) {
|
|
961
|
+
const encoded = encodeChannelEvent({ kind: "member", summary: `${joined.content["displayname"]} joined`, status: "info", source: { system: "khala-local" } });
|
|
962
|
+
if (encoded.ok) await ctx.store.append(caller.roomId, {
|
|
963
|
+
type: "com.khala.event.v1",
|
|
964
|
+
sender: LOCAL_OWNER_USER_ID,
|
|
965
|
+
content: { ...encoded.value },
|
|
966
|
+
txnId: announcementTxn(joined)
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
return event ? { status: 200, json: { seq: event.seq, ts: event.ts } } : fail2(503, "unavailable");
|
|
970
|
+
});
|
|
971
|
+
}),
|
|
972
|
+
route("GET", /^\/api\/local\/rooms\/([^/]+)\/events$/u, "joined", async (req, params, ctx, caller) => {
|
|
973
|
+
const rawAfter = req.query.get("after") ?? "0";
|
|
974
|
+
const rawWait = req.query.get("wait") ?? "0";
|
|
975
|
+
if (!/^\d{1,15}$/u.test(rawAfter) || !/^\d{1,2}$/u.test(rawWait) || Number(rawWait) > LOCAL_LONG_POLL_MAX_S) return fail2(400, "invalid_request");
|
|
976
|
+
const after = Number(rawAfter);
|
|
977
|
+
const wait = Number(rawWait);
|
|
978
|
+
if (req.signal.aborted) return { status: 200, json: { events: [], next: after } };
|
|
979
|
+
let events = ctx.store.eventsAfter(caller.roomId, after, 200);
|
|
980
|
+
if (events.length === 0 && wait > 0) {
|
|
981
|
+
await ctx.store.waitForEvent(caller.roomId, after, wait * 1e3, req.signal);
|
|
982
|
+
if (req.signal.aborted) return { status: 200, json: { events: [], next: after } };
|
|
983
|
+
const current = resolve(req, params, ctx, "joined");
|
|
984
|
+
if ("status" in current) return current;
|
|
985
|
+
events = ctx.store.eventsAfter(caller.roomId, after, 200);
|
|
986
|
+
}
|
|
987
|
+
return { status: 200, json: { events, next: events.at(-1)?.seq ?? after } };
|
|
988
|
+
}),
|
|
989
|
+
route("GET", /^\/api\/local\/rooms\/([^/]+)\/messages$/u, "joined", async (req, _params, ctx, caller) => {
|
|
990
|
+
const before = req.query.get("before");
|
|
991
|
+
const limit = req.query.get("limit") ?? "50";
|
|
992
|
+
if (before !== null && !/^\$[A-Za-z0-9_-]{1,128}$/u.test(before) || !/^\d{1,3}$/u.test(limit) || Number(limit) < 1 || Number(limit) > 100) return fail2(400, "invalid_request");
|
|
993
|
+
return { status: 200, json: ctx.store.history(caller.roomId, before ?? void 0, Number(limit)) };
|
|
994
|
+
}),
|
|
995
|
+
route("POST", /^\/api\/local\/rooms\/([^/]+)\/send$/u, "joined", async (req, params, ctx) => {
|
|
996
|
+
const decoded = decodeWith(() => {
|
|
997
|
+
const r = object(req.body, "", ["txnId", "type", "content"]);
|
|
998
|
+
return { txnId: r.field("txnId"), type: r.field("type"), content: r.field("content") };
|
|
999
|
+
});
|
|
1000
|
+
if (!decoded.ok) return fail2(400, "invalid_request");
|
|
1001
|
+
const { txnId, type, content } = decoded.value;
|
|
1002
|
+
if (typeof txnId !== "string" || !/^[A-Za-z0-9._-]{1,64}$/u.test(txnId) || type !== "m.room.message" && type !== "com.khala.event.v1" || !isPlainObject(content)) return fail2(400, "invalid_request");
|
|
1003
|
+
if (utf8Length(JSON.stringify(content)) > 32768) return fail2(413, "payload_too_large");
|
|
1004
|
+
let stored = content;
|
|
1005
|
+
if (type === "m.room.message") {
|
|
1006
|
+
if (content["msgtype"] !== "m.text" && content["msgtype"] !== "m.notice" || typeof content["body"] !== "string" || content["body"].length < 1 || content["body"].length > 8e3) return fail2(400, "invalid_request");
|
|
1007
|
+
} else {
|
|
1008
|
+
const event = decodeChannelEvent(content);
|
|
1009
|
+
if (!event.ok) return fail2(400, "invalid_request");
|
|
1010
|
+
stored = { ...event.value };
|
|
1011
|
+
}
|
|
1012
|
+
return enqueue(async () => {
|
|
1013
|
+
const caller = resolve(req, params, ctx, "joined");
|
|
1014
|
+
if ("status" in caller) return caller;
|
|
1015
|
+
const event = await ctx.store.append(caller.roomId, { type, sender: caller.userId, content: stored, txnId });
|
|
1016
|
+
return { status: 200, json: { eventId: event.eventId } };
|
|
1017
|
+
});
|
|
1018
|
+
}),
|
|
1019
|
+
route("GET", /^\/api\/local\/rooms\/([^/]+)\/members$/u, "joined", async (_req, _params, ctx, caller) => ({
|
|
1020
|
+
status: 200,
|
|
1021
|
+
json: { members: ctx.store.members(caller.roomId) }
|
|
1022
|
+
})),
|
|
1023
|
+
route("PUT", /^\/api\/local\/rooms\/([^/]+)\/members\/([^/]+)$/u, "joined", async (req, params, ctx, caller) => {
|
|
1024
|
+
let target;
|
|
1025
|
+
try {
|
|
1026
|
+
target = decodeURIComponent(params[1]);
|
|
1027
|
+
} catch {
|
|
1028
|
+
return fail2(400, "invalid_request");
|
|
1029
|
+
}
|
|
1030
|
+
if (caller.kind !== "agent" || target !== caller.userId) return fail2(409, "conflict");
|
|
1031
|
+
const body = decodeWith(() => object(req.body, "", ["listeningMode"]).field("listeningMode"));
|
|
1032
|
+
const mode = decodeListeningMode(body.ok ? body.value : void 0);
|
|
1033
|
+
if (!body.ok || !mode.ok) return fail2(400, "invalid_request");
|
|
1034
|
+
return enqueue(async () => {
|
|
1035
|
+
const current = resolve(req, params, ctx, "joined");
|
|
1036
|
+
if ("status" in current) return current;
|
|
1037
|
+
await ctx.store.append(current.roomId, {
|
|
1038
|
+
type: "m.room.member",
|
|
1039
|
+
sender: current.userId,
|
|
1040
|
+
content: memberContent(current.member, { [LISTENING_MODE_MEMBER_KEY]: mode.value })
|
|
1041
|
+
});
|
|
1042
|
+
return { status: 204 };
|
|
1043
|
+
});
|
|
1044
|
+
})
|
|
1045
|
+
];
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
// src/local/routes/owner.ts
|
|
1049
|
+
var ID = /^[A-Za-z0-9._-]{1,64}$/u;
|
|
1050
|
+
var USER = /^@[^\s:]+:\S+$/u;
|
|
1051
|
+
var fail3 = (status, error2) => ({ status, json: { error: error2 } });
|
|
1052
|
+
function serial3() {
|
|
1053
|
+
let tail = Promise.resolve();
|
|
1054
|
+
return (work) => {
|
|
1055
|
+
const result = tail.then(work);
|
|
1056
|
+
tail = result.then(() => void 0, () => void 0);
|
|
1057
|
+
return result;
|
|
1058
|
+
};
|
|
1059
|
+
}
|
|
1060
|
+
function owner(req, adminOnly) {
|
|
1061
|
+
if (req.auth.kind === "none") return fail3(401, "unauthorized");
|
|
1062
|
+
if (req.auth.kind !== "owner" || adminOnly && req.auth.via !== "admin") return fail3(403, "forbidden");
|
|
1063
|
+
return null;
|
|
1064
|
+
}
|
|
1065
|
+
function plainObject(value) {
|
|
1066
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
|
|
1067
|
+
}
|
|
1068
|
+
function decodeParam(raw) {
|
|
1069
|
+
if (raw === void 0) return null;
|
|
1070
|
+
try {
|
|
1071
|
+
return decodeURIComponent(raw);
|
|
1072
|
+
} catch {
|
|
1073
|
+
return null;
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
function channelParam(raw, store) {
|
|
1077
|
+
const id = decodeParam(raw);
|
|
1078
|
+
return id !== null && store.hasChannel(id) ? id : null;
|
|
1079
|
+
}
|
|
1080
|
+
function memberContent2(member, membership, overrides = {}) {
|
|
1081
|
+
return {
|
|
1082
|
+
user: member.userId,
|
|
1083
|
+
membership,
|
|
1084
|
+
displayname: member.displayName,
|
|
1085
|
+
kind: member.kind,
|
|
1086
|
+
...member.harness ? { harness: member.harness } : {},
|
|
1087
|
+
...member.kind === "agent" ? { [LISTENING_MODE_MEMBER_KEY]: member.listeningMode ?? DEFAULT_LISTENING_MODE } : {},
|
|
1088
|
+
...overrides
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
1091
|
+
async function announce(store, roomId, summary) {
|
|
1092
|
+
const encoded = encodeChannelEvent({ kind: "member", summary, status: "info", source: { system: "khala-local" } });
|
|
1093
|
+
if (encoded.ok) await store.append(roomId, { type: CHANNEL_EVENT_TYPE, sender: LOCAL_OWNER_USER_ID, content: encoded.value });
|
|
1094
|
+
}
|
|
1095
|
+
function ownerRoutes(options = {}) {
|
|
1096
|
+
const queue = options.queue ?? serial3();
|
|
1097
|
+
function route(method, pattern, handler, options2 = {}) {
|
|
1098
|
+
return { method, pattern, async handle(req, params, ctx) {
|
|
1099
|
+
try {
|
|
1100
|
+
if (!options2.public) {
|
|
1101
|
+
const denied = owner(req, options2.adminOnly ?? false);
|
|
1102
|
+
if (denied) return denied;
|
|
1103
|
+
}
|
|
1104
|
+
return await (options2.serial ? queue(() => handler(req, params, ctx)) : handler(req, params, ctx));
|
|
1105
|
+
} catch {
|
|
1106
|
+
return fail3(503, "unavailable");
|
|
1107
|
+
}
|
|
1108
|
+
} };
|
|
1109
|
+
}
|
|
1110
|
+
return [
|
|
1111
|
+
route("GET", /^\/api\/local\/channels$/u, async (req, _params, { store }) => {
|
|
1112
|
+
const since = req.query.get("since");
|
|
1113
|
+
const wait = req.query.get("wait") ?? "0";
|
|
1114
|
+
if (since !== null && !/^\d{1,15}$/u.test(since) || !/^\d{1,2}$/u.test(wait) || Number(wait) > LOCAL_LONG_POLL_MAX_S) return fail3(400, "invalid_request");
|
|
1115
|
+
if (since !== null && Number(since) === store.revision() && Number(wait) > 0) await store.waitForRevision(Number(since), Number(wait) * 1e3, req.signal);
|
|
1116
|
+
return { status: 200, json: { revision: store.revision(), channels: store.listChannels() } };
|
|
1117
|
+
}),
|
|
1118
|
+
route("GET", /^\/api\/local\/channels\/by-operation\/([^/]+)$/u, async (_req, params, { store }) => {
|
|
1119
|
+
const id = decodeParam(params[0]);
|
|
1120
|
+
if (id === null || !ID.test(id)) return fail3(404, "not_found");
|
|
1121
|
+
const roomId = store.findByOperation(id);
|
|
1122
|
+
return roomId === void 0 ? fail3(404, "not_found") : { status: 200, json: { roomId } };
|
|
1123
|
+
}),
|
|
1124
|
+
route("GET", /^\/api\/local\/channels\/([^/]+)$/u, async (_req, params, { store }) => {
|
|
1125
|
+
const roomId = channelParam(params[0], store);
|
|
1126
|
+
const summary = roomId === null ? void 0 : store.channelSummary(roomId);
|
|
1127
|
+
return summary === void 0 ? fail3(404, "not_found") : { status: 200, json: summary };
|
|
1128
|
+
}),
|
|
1129
|
+
route("POST", /^\/api\/local\/channels$/u, async (req, _params, ctx) => {
|
|
1130
|
+
const body = req.body;
|
|
1131
|
+
if (!plainObject(body) || !Object.hasOwn(body, "name") || Object.keys(body).some((key) => key !== "name" && key !== "operationId") || typeof body.name !== "string") return fail3(400, "invalid_request");
|
|
1132
|
+
const name = body.name.trim();
|
|
1133
|
+
if ([...name].length < 1 || [...name].length > 64 || /[\u0000-\u001f\u007f]/u.test(name)) return fail3(400, "invalid_request");
|
|
1134
|
+
if (Object.hasOwn(body, "operationId") && (typeof body.operationId !== "string" || !ID.test(body.operationId))) return fail3(400, "invalid_request");
|
|
1135
|
+
const { store } = ctx;
|
|
1136
|
+
const { roomId } = await store.createChannel(name, body.operationId);
|
|
1137
|
+
if (store.member(roomId, LOCAL_OWNER_USER_ID)?.membership !== "join") await store.append(roomId, {
|
|
1138
|
+
type: "m.room.member",
|
|
1139
|
+
sender: LOCAL_OWNER_USER_ID,
|
|
1140
|
+
content: { user: LOCAL_OWNER_USER_ID, membership: "join", displayname: store.owner().username, kind: "human" }
|
|
1141
|
+
});
|
|
1142
|
+
const self = await store.mintLink(roomId, "join");
|
|
1143
|
+
const share = await store.mintLink(roomId, "join");
|
|
1144
|
+
const open2 = ctx.mintOpenToken(roomId);
|
|
1145
|
+
return { status: 201, json: { roomId, name: store.channelName(roomId), selfLink: `${ctx.origin}/join/${self.token}`, shareLink: `${ctx.origin}/join/${share.token}`, openUrl: `${ctx.origin}/open/${open2.token}`, expiresAt: self.expiresAt } };
|
|
1146
|
+
}, { serial: true }),
|
|
1147
|
+
route("DELETE", /^\/api\/local\/channels\/([^/]+)$/u, async (_req, params, ctx) => {
|
|
1148
|
+
const roomId = channelParam(params[0], ctx.store);
|
|
1149
|
+
if (roomId === null) return fail3(404, "not_found");
|
|
1150
|
+
await ctx.store.deleteChannel(roomId);
|
|
1151
|
+
for (const [id, join] of ctx.joins) if (join.roomId === roomId) ctx.joins.delete(id);
|
|
1152
|
+
return { status: 204 };
|
|
1153
|
+
}, { serial: true }),
|
|
1154
|
+
route("POST", /^\/api\/local\/channels\/([^/]+)\/links$/u, async (_req, params, ctx) => {
|
|
1155
|
+
const roomId = channelParam(params[0], ctx.store);
|
|
1156
|
+
if (roomId === null) return fail3(404, "not_found");
|
|
1157
|
+
const link = await ctx.store.mintLink(roomId, "join");
|
|
1158
|
+
return { status: 200, json: { shareLink: `${ctx.origin}/join/${link.token}`, expiresAt: link.expiresAt } };
|
|
1159
|
+
}, { serial: true }),
|
|
1160
|
+
route("POST", /^\/api\/local\/channels\/([^/]+)\/mode$/u, async (req, params, { store }) => {
|
|
1161
|
+
const roomId = channelParam(params[0], store);
|
|
1162
|
+
if (roomId === null) return fail3(404, "not_found");
|
|
1163
|
+
const body = decodeWith(() => {
|
|
1164
|
+
const reader = object(req.body, "", ["agent", "mode", "txnId"]);
|
|
1165
|
+
return { agent: reader.field("agent"), mode: reader.field("mode"), txnId: reader.field("txnId") };
|
|
1166
|
+
});
|
|
1167
|
+
if (!body.ok || typeof body.value.txnId !== "string" || !ID.test(body.value.txnId)) return fail3(400, "invalid_request");
|
|
1168
|
+
const command = decodeListeningModeCommand({ v: 1, agent: body.value.agent, mode: body.value.mode });
|
|
1169
|
+
if (!command.ok) return fail3(400, "invalid_request");
|
|
1170
|
+
const member = store.member(roomId, command.value.agent);
|
|
1171
|
+
if (!member || member.membership === "leave" || member.kind !== "agent") return fail3(404, "not_found");
|
|
1172
|
+
const event = await store.append(roomId, { type: LISTENING_MODE_COMMAND_TYPE, sender: LOCAL_OWNER_USER_ID, content: command.value, txnId: body.value.txnId });
|
|
1173
|
+
return { status: 200, json: { eventId: event.eventId } };
|
|
1174
|
+
}, { serial: true }),
|
|
1175
|
+
route("POST", /^\/api\/local\/agents\/([^/]+)\/name$/u, async (req, params, { store }) => {
|
|
1176
|
+
const userId = decodeParam(params[0]);
|
|
1177
|
+
if (userId === null || !USER.test(userId)) return fail3(400, "invalid_request");
|
|
1178
|
+
const body = decodeWith(() => object(req.body, "", ["name"]).field("name"));
|
|
1179
|
+
if (!body.ok || typeof body.value !== "string") return fail3(400, "invalid_request");
|
|
1180
|
+
const checked = checkName(body.value, "agent");
|
|
1181
|
+
if (!checked.ok) return { status: 400, json: { error: "invalid_name", reason: checked.error } };
|
|
1182
|
+
const roomId = store.channelOfMember(userId);
|
|
1183
|
+
if (roomId === void 0) return fail3(404, "not_found");
|
|
1184
|
+
const member = store.member(roomId, userId);
|
|
1185
|
+
if (!member || member.membership === "leave" || member.kind !== "agent") return fail3(404, "not_found");
|
|
1186
|
+
if (store.members(roomId).some((other) => other.userId !== userId && other.displayName.toLowerCase() === checked.name.toLowerCase())) return fail3(409, "name_taken");
|
|
1187
|
+
if (member.displayName !== checked.name) await store.append(roomId, { type: "m.room.member", sender: LOCAL_OWNER_USER_ID, content: memberContent2(member, member.membership, { displayname: checked.name }) });
|
|
1188
|
+
return { status: 200, json: { matrixUserId: userId, name: checked.name } };
|
|
1189
|
+
}, { serial: true }),
|
|
1190
|
+
route("DELETE", /^\/api\/local\/channels\/([^/]+)\/members\/([^/]+)$/u, async (_req, params, ctx) => {
|
|
1191
|
+
const roomId = channelParam(params[0], ctx.store);
|
|
1192
|
+
if (roomId === null) return fail3(404, "not_found");
|
|
1193
|
+
const userId = decodeParam(params[1]);
|
|
1194
|
+
if (userId === null || !USER.test(userId) || userId === LOCAL_OWNER_USER_ID) return fail3(400, "invalid_request");
|
|
1195
|
+
const member = ctx.store.member(roomId, userId);
|
|
1196
|
+
if (!member || member.membership === "leave") return fail3(404, "not_found");
|
|
1197
|
+
await ctx.store.append(roomId, { type: "m.room.member", sender: LOCAL_OWNER_USER_ID, content: memberContent2(member, "leave") });
|
|
1198
|
+
await announce(ctx.store, roomId, `${member.displayName} left`);
|
|
1199
|
+
for (const [id, join] of ctx.joins) if (join.credentials.userId === userId) ctx.joins.delete(id);
|
|
1200
|
+
return { status: 204 };
|
|
1201
|
+
}, { serial: true }),
|
|
1202
|
+
route("POST", /^\/api\/local\/open$/u, async (req, _params, ctx) => {
|
|
1203
|
+
const body = req.body === void 0 ? {} : req.body;
|
|
1204
|
+
if (!plainObject(body) || Object.keys(body).some((key) => key !== "roomId") || Object.hasOwn(body, "roomId") && typeof body.roomId !== "string") return fail3(400, "invalid_request");
|
|
1205
|
+
const roomId = body.roomId;
|
|
1206
|
+
if (roomId !== void 0 && !ctx.store.hasChannel(roomId)) return fail3(404, "not_found");
|
|
1207
|
+
const link = ctx.mintOpenToken(roomId);
|
|
1208
|
+
return { status: 200, json: { openUrl: `${ctx.origin}/open/${link.token}`, expiresAt: link.expiresAt } };
|
|
1209
|
+
}, { adminOnly: true }),
|
|
1210
|
+
route("GET", /^\/open\/([A-Za-z0-9_-]{43})$/u, async (_req, params, ctx) => {
|
|
1211
|
+
const opened = ctx.consumeOpenToken(params[0]);
|
|
1212
|
+
if (opened === null) return fail3(404, "link_unavailable");
|
|
1213
|
+
const sid = ctx.createOwnerSession();
|
|
1214
|
+
const location = opened.roomId && ctx.store.hasChannel(opened.roomId) ? `/channels/${encodeURIComponent(opened.roomId)}` : "/conversations";
|
|
1215
|
+
return { status: 302, location, headers: { "set-cookie": `khala_local_owner=${sid}; HttpOnly; SameSite=Strict; Path=/`, "cache-control": "no-store", "referrer-policy": "no-referrer" } };
|
|
1216
|
+
}, { public: true }),
|
|
1217
|
+
route("POST", /^\/api\/local\/shutdown$/u, async (_req, _params, ctx) => {
|
|
1218
|
+
ctx.shutdown();
|
|
1219
|
+
return { status: 204 };
|
|
1220
|
+
}, { adminOnly: true })
|
|
1221
|
+
];
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
// src/local/routes/profile.ts
|
|
1225
|
+
var fail4 = (status, error2) => ({ status, json: { error: error2 } });
|
|
1226
|
+
var field = (body, key) => decodeWith(() => object(body, "", [key]).field(key));
|
|
1227
|
+
var iso = (ctx) => new Date(ctx.now()).toISOString();
|
|
1228
|
+
async function cascade(store, previous, next) {
|
|
1229
|
+
for (const { roomId } of store.listChannels()) {
|
|
1230
|
+
try {
|
|
1231
|
+
const members = store.members(roomId);
|
|
1232
|
+
const taken = new Set(members.map((m) => m.displayName.toLowerCase()));
|
|
1233
|
+
const owner2 = members.find((m) => m.userId === LOCAL_OWNER_USER_ID);
|
|
1234
|
+
if (owner2 && owner2.displayName !== next) {
|
|
1235
|
+
try {
|
|
1236
|
+
await store.append(roomId, {
|
|
1237
|
+
type: "m.room.member",
|
|
1238
|
+
sender: LOCAL_OWNER_USER_ID,
|
|
1239
|
+
content: { user: LOCAL_OWNER_USER_ID, membership: owner2.membership, displayname: next, kind: "human" }
|
|
1240
|
+
});
|
|
1241
|
+
taken.delete(owner2.displayName.toLowerCase());
|
|
1242
|
+
taken.add(next.toLowerCase());
|
|
1243
|
+
} catch {
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
for (const m of members) {
|
|
1247
|
+
if (m.kind !== "agent" || !m.harness || !isDefaultAgentName(m.displayName, previous, m.harness)) continue;
|
|
1248
|
+
const n = Number(/-(\d+)$/u.exec(m.displayName)?.[1] ?? 1);
|
|
1249
|
+
if (!Number.isSafeInteger(n) || n < 1) continue;
|
|
1250
|
+
const oldKey = m.displayName.toLowerCase();
|
|
1251
|
+
taken.delete(oldKey);
|
|
1252
|
+
try {
|
|
1253
|
+
let name = defaultAgentName(next, m.harness, n);
|
|
1254
|
+
if (taken.has(name.toLowerCase())) {
|
|
1255
|
+
let k = 1;
|
|
1256
|
+
do {
|
|
1257
|
+
name = defaultAgentName(next, m.harness, k++);
|
|
1258
|
+
} while (taken.has(name.toLowerCase()));
|
|
1259
|
+
}
|
|
1260
|
+
if (!checkName(name, "agent").ok) {
|
|
1261
|
+
taken.add(oldKey);
|
|
1262
|
+
continue;
|
|
1263
|
+
}
|
|
1264
|
+
if (name !== m.displayName) {
|
|
1265
|
+
await store.append(roomId, {
|
|
1266
|
+
type: "m.room.member",
|
|
1267
|
+
sender: LOCAL_OWNER_USER_ID,
|
|
1268
|
+
content: {
|
|
1269
|
+
user: m.userId,
|
|
1270
|
+
membership: m.membership,
|
|
1271
|
+
displayname: name,
|
|
1272
|
+
kind: "agent",
|
|
1273
|
+
harness: m.harness,
|
|
1274
|
+
[LISTENING_MODE_MEMBER_KEY]: m.listeningMode ?? DEFAULT_LISTENING_MODE
|
|
1275
|
+
}
|
|
1276
|
+
});
|
|
1277
|
+
}
|
|
1278
|
+
taken.add(name.toLowerCase());
|
|
1279
|
+
} catch {
|
|
1280
|
+
taken.add(oldKey);
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
} catch {
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
function profileRoutes(options = {}) {
|
|
1288
|
+
const queue = options.queue ?? serial3();
|
|
1289
|
+
const route = (method, pattern, run) => ({
|
|
1290
|
+
method,
|
|
1291
|
+
pattern,
|
|
1292
|
+
async handle(req, _params, ctx) {
|
|
1293
|
+
if (req.auth.kind === "none") return fail4(401, "unauthorized");
|
|
1294
|
+
if (req.auth.kind === "agent") return fail4(403, "forbidden");
|
|
1295
|
+
try {
|
|
1296
|
+
return await queue(() => run(req, ctx));
|
|
1297
|
+
} catch {
|
|
1298
|
+
return fail4(503, "unavailable");
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
});
|
|
1302
|
+
return [
|
|
1303
|
+
route("GET", /^\/api\/local\/profile$/u, async (_req, ctx) => {
|
|
1304
|
+
const p = ctx.store.owner();
|
|
1305
|
+
const profile = {
|
|
1306
|
+
userId: LOCAL_OWNER_USER_ID,
|
|
1307
|
+
ownerId: LOCAL_OWNER_ID,
|
|
1308
|
+
username: p.username,
|
|
1309
|
+
suggestion: p.username,
|
|
1310
|
+
color: p.color,
|
|
1311
|
+
initials: p.initials
|
|
1312
|
+
};
|
|
1313
|
+
return { status: 200, json: profile };
|
|
1314
|
+
}),
|
|
1315
|
+
route("POST", /^\/api\/local\/profile\/username$/u, async (req, ctx) => {
|
|
1316
|
+
const parsed = field(req.body, "username");
|
|
1317
|
+
if (!parsed.ok) return fail4(400, "invalid_request");
|
|
1318
|
+
const checked = checkName(parsed.value, "username");
|
|
1319
|
+
if (!checked.ok) return { status: 400, json: { error: "invalid_username", reason: checked.error } };
|
|
1320
|
+
const username = checked.name;
|
|
1321
|
+
const p = ctx.store.owner();
|
|
1322
|
+
if (p.username !== username) {
|
|
1323
|
+
await ctx.store.setOwner({ ...p, username, updatedAt: iso(ctx) });
|
|
1324
|
+
try {
|
|
1325
|
+
await cascade(ctx.store, p.username, username);
|
|
1326
|
+
} catch {
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
return { status: 200, json: { username } };
|
|
1330
|
+
}),
|
|
1331
|
+
route("POST", /^\/api\/local\/profile\/color$/u, async (req, ctx) => {
|
|
1332
|
+
const parsed = field(req.body, "color");
|
|
1333
|
+
if (!parsed.ok) return fail4(400, "invalid_request");
|
|
1334
|
+
if (!isHumanColorId(parsed.value)) return fail4(400, "invalid_color");
|
|
1335
|
+
const color = parsed.value;
|
|
1336
|
+
const p = ctx.store.owner();
|
|
1337
|
+
if (p.color !== color) await ctx.store.setOwner({ ...p, color, updatedAt: iso(ctx) });
|
|
1338
|
+
return { status: 200, json: { color } };
|
|
1339
|
+
}),
|
|
1340
|
+
route("POST", /^\/api\/local\/profile\/initials$/u, async (req, ctx) => {
|
|
1341
|
+
const parsed = field(req.body, "initials");
|
|
1342
|
+
if (!parsed.ok) return fail4(400, "invalid_request");
|
|
1343
|
+
const initials = parsed.value === null ? null : normalizeInitials(parsed.value);
|
|
1344
|
+
if (parsed.value !== null && initials === null) return fail4(400, "invalid_initials");
|
|
1345
|
+
const p = ctx.store.owner();
|
|
1346
|
+
if (p.initials !== initials) await ctx.store.setOwner({ ...p, initials, updatedAt: iso(ctx) });
|
|
1347
|
+
return { status: 200, json: { initials } };
|
|
1348
|
+
})
|
|
1349
|
+
];
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
// src/local/serve.ts
|
|
1353
|
+
function defaultWebDir() {
|
|
1354
|
+
return fileURLToPath(bundle ? new URL("./web/", import.meta.url) : new URL("../../../../apps/web/dist-local/", import.meta.url));
|
|
1355
|
+
}
|
|
1356
|
+
var hash = (token) => createHash3("sha256").update(token).digest();
|
|
1357
|
+
async function runHelper(options = {}) {
|
|
1358
|
+
const env = options.env ?? process.env;
|
|
1359
|
+
const now = options.now ?? Date.now;
|
|
1360
|
+
const random = options.random ?? randomBytes2;
|
|
1361
|
+
const stderr = options.stderr ?? ((line) => {
|
|
1362
|
+
process.stderr.write(line);
|
|
1363
|
+
});
|
|
1364
|
+
let store;
|
|
1365
|
+
let server;
|
|
1366
|
+
let boundPort;
|
|
1367
|
+
let stop;
|
|
1368
|
+
const stopped = new Promise((resolve2) => {
|
|
1369
|
+
stop = resolve2;
|
|
1370
|
+
});
|
|
1371
|
+
let shutdownTimer;
|
|
1372
|
+
try {
|
|
1373
|
+
const paths = helperPaths(env);
|
|
1374
|
+
const port = options.port ?? paths.port;
|
|
1375
|
+
const idleValue = env.KHALA_LOCAL_IDLE_MS;
|
|
1376
|
+
const idleMs = options.idleMs ?? (idleValue && /^\d+$/u.test(idleValue) && Number.isSafeInteger(Number(idleValue)) && Number(idleValue) > 0 ? Number(idleValue) : LOCAL_IDLE_EXIT_MS);
|
|
1377
|
+
const webDir = options.webDir ?? (env.KHALA_LOCAL_WEB_DIR && path3.isAbsolute(env.KHALA_LOCAL_WEB_DIR) ? env.KHALA_LOCAL_WEB_DIR : defaultWebDir());
|
|
1378
|
+
store = await openLocalStore({ root: paths.root, ownerDefault: {
|
|
1379
|
+
v: 1,
|
|
1380
|
+
initials: null,
|
|
1381
|
+
username: await resolveLocalOwnerName(env),
|
|
1382
|
+
color: defaultHumanColor(LOCAL_OWNER_USER_ID),
|
|
1383
|
+
updatedAt: new Date(now()).toISOString()
|
|
1384
|
+
}, now, random });
|
|
1385
|
+
const localStore = store;
|
|
1386
|
+
const adminToken = base64url(random(32));
|
|
1387
|
+
const adminHash = hash(adminToken);
|
|
1388
|
+
const sessions = /* @__PURE__ */ new Set();
|
|
1389
|
+
const openTokens = /* @__PURE__ */ new Map();
|
|
1390
|
+
const queue = serial3();
|
|
1391
|
+
server = createHelperServer({
|
|
1392
|
+
port,
|
|
1393
|
+
webDir,
|
|
1394
|
+
idleMs,
|
|
1395
|
+
onIdle: stop,
|
|
1396
|
+
routes: [...agentJoinRoutes(), ...roomRoutes(), ...ownerRoutes({ queue }), ...profileRoutes({ queue })],
|
|
1397
|
+
authenticateCookie: (value) => sessions.has(value),
|
|
1398
|
+
authenticateBearer: (token) => {
|
|
1399
|
+
if (timingSafeEqual3(hash(token), adminHash)) return { kind: "owner", via: "admin" };
|
|
1400
|
+
const agent = localStore.agentForToken(token);
|
|
1401
|
+
return agent ? { kind: "agent", ...agent } : { kind: "none" };
|
|
1402
|
+
},
|
|
1403
|
+
createContext: (origin) => ({
|
|
1404
|
+
store: localStore,
|
|
1405
|
+
origin,
|
|
1406
|
+
now,
|
|
1407
|
+
random,
|
|
1408
|
+
version: KHALA_AGENT_VERSION,
|
|
1409
|
+
joins: /* @__PURE__ */ new Map(),
|
|
1410
|
+
mintOpenToken(roomId) {
|
|
1411
|
+
const token = base64url(random(32));
|
|
1412
|
+
const expiresAt = now() + LOCAL_LINK_TTL_MS;
|
|
1413
|
+
openTokens.set(hash(token).toString("hex"), { ...roomId === void 0 ? {} : { roomId }, expiresAt });
|
|
1414
|
+
return { token, expiresAt: new Date(expiresAt).toISOString() };
|
|
1415
|
+
},
|
|
1416
|
+
consumeOpenToken(token) {
|
|
1417
|
+
const key = hash(token).toString("hex");
|
|
1418
|
+
const entry = openTokens.get(key);
|
|
1419
|
+
openTokens.delete(key);
|
|
1420
|
+
return !entry || entry.expiresAt <= now() ? null : entry.roomId === void 0 ? {} : { roomId: entry.roomId };
|
|
1421
|
+
},
|
|
1422
|
+
createOwnerSession() {
|
|
1423
|
+
const token = base64url(random(32));
|
|
1424
|
+
sessions.add(token);
|
|
1425
|
+
return token;
|
|
1426
|
+
},
|
|
1427
|
+
shutdown() {
|
|
1428
|
+
shutdownTimer ??= setTimeout(stop, 25);
|
|
1429
|
+
}
|
|
1430
|
+
})
|
|
1431
|
+
});
|
|
1432
|
+
let bound;
|
|
1433
|
+
try {
|
|
1434
|
+
bound = await server.listen();
|
|
1435
|
+
} catch (error2) {
|
|
1436
|
+
await store.close();
|
|
1437
|
+
if (error2.code === "EADDRINUSE") {
|
|
1438
|
+
try {
|
|
1439
|
+
const response = await fetch(`http://127.0.0.1:${port}/healthz`, { signal: AbortSignal.timeout(500) });
|
|
1440
|
+
if (response.status === 200 && (await response.json()).ok === true) return 0;
|
|
1441
|
+
await response.body?.cancel();
|
|
1442
|
+
} catch {
|
|
1443
|
+
}
|
|
1444
|
+
stderr('{"error":"port_in_use"}\n');
|
|
1445
|
+
} else stderr('{"error":"listen_failed"}\n');
|
|
1446
|
+
return 1;
|
|
1447
|
+
}
|
|
1448
|
+
boundPort = bound.port;
|
|
1449
|
+
process.once("SIGTERM", stop);
|
|
1450
|
+
process.once("SIGINT", stop);
|
|
1451
|
+
options.signal?.addEventListener("abort", stop, { once: true });
|
|
1452
|
+
if (options.signal?.aborted) stop();
|
|
1453
|
+
await store.setOwner(store.owner());
|
|
1454
|
+
await writeJsonAtomic(paths.helperFile, { v: 1, pid: process.pid, ...bound, adminToken, version: KHALA_AGENT_VERSION, startedAt: new Date(now()).toISOString() });
|
|
1455
|
+
await stopped;
|
|
1456
|
+
return 0;
|
|
1457
|
+
} catch (error2) {
|
|
1458
|
+
stderr(JSON.stringify({ error: error2 instanceof StateError || error2 instanceof LocalStoreError ? error2.code : "internal_error" }) + "\n");
|
|
1459
|
+
return 1;
|
|
1460
|
+
} finally {
|
|
1461
|
+
process.off("SIGTERM", stop);
|
|
1462
|
+
process.off("SIGINT", stop);
|
|
1463
|
+
options.signal?.removeEventListener("abort", stop);
|
|
1464
|
+
if (shutdownTimer) clearTimeout(shutdownTimer);
|
|
1465
|
+
let cleanupFailed = false;
|
|
1466
|
+
try {
|
|
1467
|
+
await server?.close();
|
|
1468
|
+
} catch {
|
|
1469
|
+
cleanupFailed = true;
|
|
1470
|
+
}
|
|
1471
|
+
try {
|
|
1472
|
+
await store?.close();
|
|
1473
|
+
} catch {
|
|
1474
|
+
cleanupFailed = true;
|
|
1475
|
+
}
|
|
1476
|
+
try {
|
|
1477
|
+
if (boundPort !== void 0) {
|
|
1478
|
+
const file = await readHelperFile(env);
|
|
1479
|
+
if (file?.pid === process.pid && file.port === boundPort) await unlink(helperPaths(env).helperFile);
|
|
1480
|
+
}
|
|
1481
|
+
} catch {
|
|
1482
|
+
cleanupFailed = true;
|
|
1483
|
+
}
|
|
1484
|
+
if (cleanupFailed) {
|
|
1485
|
+
stderr('{"error":"storage_failed"}\n');
|
|
1486
|
+
return 1;
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
// src/local/cli.ts
|
|
1492
|
+
var CliError = class extends Error {
|
|
1493
|
+
constructor(code) {
|
|
1494
|
+
super(code);
|
|
1495
|
+
this.code = code;
|
|
1496
|
+
}
|
|
1497
|
+
code;
|
|
1498
|
+
};
|
|
1499
|
+
var arities = {
|
|
1500
|
+
create: [0, 1],
|
|
1501
|
+
link: [1, 1],
|
|
1502
|
+
open: [0, 1],
|
|
1503
|
+
list: [0, 0],
|
|
1504
|
+
delete: [1, 1],
|
|
1505
|
+
status: [0, 0],
|
|
1506
|
+
stop: [0, 0],
|
|
1507
|
+
serve: [0, 0]
|
|
1508
|
+
};
|
|
1509
|
+
async function runLocalCommand(argv, deps = {}) {
|
|
1510
|
+
const env = deps.env ?? process.env;
|
|
1511
|
+
const stdout = deps.stdout ?? ((line) => {
|
|
1512
|
+
process.stdout.write(line);
|
|
1513
|
+
});
|
|
1514
|
+
const stderr = deps.stderr ?? ((line) => {
|
|
1515
|
+
process.stderr.write(line);
|
|
1516
|
+
});
|
|
1517
|
+
const fetchImpl = deps.fetch ?? globalThis.fetch;
|
|
1518
|
+
const now = deps.now ?? Date.now;
|
|
1519
|
+
const print = (value) => stdout(JSON.stringify(value) + "\n");
|
|
1520
|
+
async function health(file) {
|
|
1521
|
+
try {
|
|
1522
|
+
const response = await fetchImpl(file.origin + "/healthz", { signal: AbortSignal.timeout(500) });
|
|
1523
|
+
if (response.status !== 200) {
|
|
1524
|
+
await response.body?.cancel();
|
|
1525
|
+
return null;
|
|
1526
|
+
}
|
|
1527
|
+
const body = await response.json();
|
|
1528
|
+
return body?.ok === true && body.pid === file.pid && typeof body.version === "string" ? body : null;
|
|
1529
|
+
} catch {
|
|
1530
|
+
return null;
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
try {
|
|
1534
|
+
const [command = "", ...args] = argv;
|
|
1535
|
+
const arity = arities[command];
|
|
1536
|
+
if (!arity || args.length < arity[0] || args.length > arity[1]) throw new CliError("invalid_arguments");
|
|
1537
|
+
if (command === "serve") return await (deps.serve ?? runHelper)({ env });
|
|
1538
|
+
let connection;
|
|
1539
|
+
let file = null;
|
|
1540
|
+
let healthy = null;
|
|
1541
|
+
if (command === "status" || command === "stop") {
|
|
1542
|
+
file = await (deps.readHelperFile ?? readHelperFile)(env);
|
|
1543
|
+
healthy = file ? await health(file) : null;
|
|
1544
|
+
if (!file || !healthy) {
|
|
1545
|
+
print(command === "status" ? { running: false } : { stopped: false });
|
|
1546
|
+
return 0;
|
|
1547
|
+
}
|
|
1548
|
+
connection = file;
|
|
1549
|
+
} else {
|
|
1550
|
+
try {
|
|
1551
|
+
connection = await (deps.ensureHelper ?? ensureHelper)(env);
|
|
1552
|
+
} catch (error2) {
|
|
1553
|
+
throw error2 instanceof StateError ? error2 : new CliError("helper_unavailable");
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
async function call(method, endpoint, body) {
|
|
1557
|
+
let response;
|
|
1558
|
+
try {
|
|
1559
|
+
response = await fetchImpl(connection.origin + endpoint, {
|
|
1560
|
+
method,
|
|
1561
|
+
headers: { authorization: `Bearer ${connection.adminToken}`, ...body === void 0 ? {} : { "content-type": "application/json" } },
|
|
1562
|
+
...body === void 0 ? {} : { body: JSON.stringify(body) },
|
|
1563
|
+
signal: AbortSignal.timeout(5e3)
|
|
1564
|
+
});
|
|
1565
|
+
} catch {
|
|
1566
|
+
throw new CliError("helper_unavailable");
|
|
1567
|
+
}
|
|
1568
|
+
if (response.status === 204) return null;
|
|
1569
|
+
let value;
|
|
1570
|
+
try {
|
|
1571
|
+
value = await response.json();
|
|
1572
|
+
} catch {
|
|
1573
|
+
throw new CliError(response.status === 404 ? "not_found" : "internal_error");
|
|
1574
|
+
}
|
|
1575
|
+
if (!response.ok) throw new CliError(response.status === 404 ? "not_found" : typeof value === "object" && value !== null && "error" in value && typeof value.error === "string" ? value.error : "internal_error");
|
|
1576
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new CliError("internal_error");
|
|
1577
|
+
return value;
|
|
1578
|
+
}
|
|
1579
|
+
async function channels() {
|
|
1580
|
+
const page = await call("GET", "/api/local/channels");
|
|
1581
|
+
if (!Array.isArray(page?.channels)) throw new CliError("internal_error");
|
|
1582
|
+
return page.channels;
|
|
1583
|
+
}
|
|
1584
|
+
async function resolveChannel(arg) {
|
|
1585
|
+
if (isLocalRoomId(arg)) return arg;
|
|
1586
|
+
const matches = (await channels()).filter((channel) => channel.name === arg);
|
|
1587
|
+
if (matches.length !== 1) throw new CliError(matches.length ? "ambiguous_channel" : "not_found");
|
|
1588
|
+
return matches[0].roomId;
|
|
1589
|
+
}
|
|
1590
|
+
async function webHint() {
|
|
1591
|
+
try {
|
|
1592
|
+
const response = await fetchImpl(connection.origin + "/", { signal: AbortSignal.timeout(500) });
|
|
1593
|
+
if (response.status === 503 && (await response.json()).error === "web_not_built") stderr("khala: web app not built; run pnpm --filter @khala/web build:local\n");
|
|
1594
|
+
else await response.body?.cancel();
|
|
1595
|
+
} catch {
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
let result;
|
|
1599
|
+
switch (command) {
|
|
1600
|
+
case "create": {
|
|
1601
|
+
const date = new Date(now());
|
|
1602
|
+
const name = args[0] ?? `local-${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
|
1603
|
+
result = await call("POST", "/api/local/channels", { name });
|
|
1604
|
+
await webHint();
|
|
1605
|
+
break;
|
|
1606
|
+
}
|
|
1607
|
+
case "link":
|
|
1608
|
+
result = await call("POST", `/api/local/channels/${encodeURIComponent(await resolveChannel(args[0]))}/links`);
|
|
1609
|
+
break;
|
|
1610
|
+
case "open":
|
|
1611
|
+
result = await call("POST", "/api/local/open", args[0] === void 0 ? {} : { roomId: await resolveChannel(args[0]) });
|
|
1612
|
+
await webHint();
|
|
1613
|
+
break;
|
|
1614
|
+
case "list":
|
|
1615
|
+
result = { channels: await channels() };
|
|
1616
|
+
break;
|
|
1617
|
+
case "delete": {
|
|
1618
|
+
const roomId = await resolveChannel(args[0]);
|
|
1619
|
+
await call("DELETE", `/api/local/channels/${encodeURIComponent(roomId)}`);
|
|
1620
|
+
result = { deleted: roomId };
|
|
1621
|
+
break;
|
|
1622
|
+
}
|
|
1623
|
+
case "status":
|
|
1624
|
+
result = { running: true, origin: file.origin, pid: file.pid, version: healthy.version, channels: (await channels()).length };
|
|
1625
|
+
break;
|
|
1626
|
+
case "stop": {
|
|
1627
|
+
if (await call("POST", "/api/local/shutdown") !== null) throw new CliError("internal_error");
|
|
1628
|
+
const deadline = now() + 2e3;
|
|
1629
|
+
for (let attempt = 0; attempt < 20 && now() < deadline; attempt++) {
|
|
1630
|
+
if (!await health(file)) break;
|
|
1631
|
+
await (deps.sleep ?? sleepDefault)(100);
|
|
1632
|
+
}
|
|
1633
|
+
result = { stopped: true };
|
|
1634
|
+
break;
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
print(result);
|
|
1638
|
+
return 0;
|
|
1639
|
+
} catch (error2) {
|
|
1640
|
+
print({ error: error2 instanceof CliError || error2 instanceof StateError ? error2.code : "internal_error" });
|
|
1641
|
+
return 1;
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
async function main(argv) {
|
|
1645
|
+
const code = await runLocalCommand(argv);
|
|
1646
|
+
if (argv.length === 1 && argv[0] === "serve") setImmediate(() => process.exit(code));
|
|
1647
|
+
return code;
|
|
1648
|
+
}
|
|
1649
|
+
export {
|
|
1650
|
+
main as default,
|
|
1651
|
+
runLocalCommand
|
|
1652
|
+
};
|