eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1#!/usr/bin/env node
2// lchat-dev-up — spin up a local lchat server from the current checkout
3// (whatever branch/worktree you run it from), pre-seeded with an invite link
4// that signs you straight in.
5//
6// Usage: tools/lchat-dev-up.mjs [--port N] [--buddy-name NAME] [--group-name NAME] [--onboarding] [--clean]
7//
8// Persistent by default. The first run writes `.lchat-dev.json` (repo root)
9// recording the admin token, db path, port, base URL, and the seeded
10// account/thread; later runs reuse all of it, so:
11// • the same admin URL keeps working,
12// • the port is stable — your browser stays signed in across restarts
13// (localStorage is keyed by origin), no re-provisioning needed,
14// • the db (and every app/message in it) survives a restart.
15// The written config is also how tools/lchat-dev-bot.mjs discovers the server:
16// run it with no --base/--admin and it reads them from `.lchat-dev.json`.
17//
18// --clean deletes the config + its db files (db, -wal, -shm) and starts fresh.
19//
20// On a fresh start it builds the Go binary from this checkout, runs it against
21// `.lchat-dev.db` + a fresh admin token, then uses web/static/crypto.js (the
22// exact crypto the browser runs) to, without ever touching a browser:
23// 1. mint an admin-issued "add a device" invite for a brand-new "you"
24// account — join.js (see its `payload.priv` branch) recognises this as a
25// device link and signs the browser straight in as that member, with no
26// name entry and no second session, exactly like using a real add-device
27// QR code
28// With --onboarding: just create the invite with no buddies or chats
29// Without --onboarding (group mode, default):
30// 2. mint + claim a second member ("Dev Buddy") via the admin API, so
31// there's someone else in the chat
32// 3. create a group thread with you + Dev Buddy and post a welcome message
33// 4. point the invite at the group thread so it opens automatically on sign-in
34// The one thing no HTTP endpoint exposes — pointing that invite at the group
35// thread so it's the one that opens automatically on sign-in — is patched
36// directly into the sqlite file via the `sqlite3` CLI, best-effort: if that's
37// unavailable the invite still works, you just land on the thread list one
38// tap away from the group instead of inside it.
39//
40// On a reused start it skips all seeding (the accounts/thread already exist) and
41// re-mints a fresh sign-in link for the stored "you" so you always have a
42// working link to hand.
43//
44// The binary is always rebuilt from the current checkout so code changes take
45// effect on restart. Ctrl-C stops the server but KEEPS the db + config (use
46// --clean to wipe them).
47//
48// It also runs the webhook-ingest sidecar (`lchat hooks run`, docs/webhooks.md)
49// on its own sticky local port — same binary, real provision/encrypt/post path,
50// no router — so the Webhooks app works end-to-end locally: the server is told
51// the port via LCHAT_HOOKS_BASE, making the app display URLs you can curl. The
52// sidecar's service identity persists in .lchat-dev-hooks.json (cleared on any
53// fresh instance, like the dev bot's). --no-hooks skips all of it. Dev-you is
54// seeded as an ADMIN member (grants_admin on the invite + a best-effort role
55// patch for pre-existing instances) so the in-app admin surfaces — Admin,
56// Webhooks — work without the console.
57
58import { spawn, execFileSync } from "node:child_process";
59import { mkdtempSync, rmSync, existsSync } from "node:fs";
60import { tmpdir } from "node:os";
61import { randomBytes } from "node:crypto";
62import net from "node:net";
63import path from "node:path";
64import { fileURLToPath } from "node:url";
65import {
66 randomSecret,
67 deriveInvite,
68 generateUserKeypair,
69 generateKey,
70 encrypt,
71 wrapThreadKey,
72} from "../web/static/crypto.js";
73import { buildInviteFragment } from "../web/static/invite.js";
74import {
75 CONFIG_PATH,
76 DEFAULT_DB_PATH,
77 BOT_SESSION_PATH,
78 HOOKS_SESSION_PATH,
79 dbFiles,
80 readConfig,
81 writeConfig,
82 clearConfig,
83 clearBotSession,
84 clearHooksSession,
85} from "./dev-config.mjs";
86
87const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
88const SAFE_ID = /^[A-Za-z0-9_-]+$/; // base64url alphabet used for every id we interpolate into SQL below
89
90function arg(name, def) {
91 const i = process.argv.indexOf(`--${name}`);
92 return i >= 0 && i + 1 < process.argv.length ? process.argv[i + 1] : def;
93}
94
95if (process.argv.includes("-h") || process.argv.includes("--help")) {
96 process.stdout.write(
97 "lchat-dev-up — build + run this checkout, pre-seeded with a sign-in link\n" +
98 " tools/lchat-dev-up.mjs [--port N] [--buddy-name NAME] [--group-name NAME] [--onboarding] [--clean]\n" +
99 "\n" +
100 " Persistent by default: reuses the admin token / db / port stored in\n" +
101 " .lchat-dev.json (repo root) so restarts keep your data and browser session.\n" +
102 " tools/lchat-dev-bot.mjs reads that file to find --base/--admin automatically.\n" +
103 "\n" +
104 " --clean delete .lchat-dev.json + its db files and start fresh\n" +
105 " --onboarding fresh instance with no buddies or chats (default: Dev Buddy + group chat)\n" +
106 " --port N override the stored/auto port (changes origin — may drop your browser session)\n" +
107 " --buddy-name / --group-name labels used only on a fresh seed\n" +
108 " --no-hooks skip the webhook-ingest sidecar (default: runs it, so the\n" +
109 " Webhooks app works end-to-end — curl its printed URL locally)\n",
110 );
111 process.exit(0);
112}
113
114const BUDDY_NAME = arg("buddy-name", "Dev Buddy");
115const GROUP_NAME = arg("group-name", "Dev chat");
116const explicitPort = arg("port");
117const isOnboarding = process.argv.includes("--onboarding");
118const isClean = process.argv.includes("--clean");
119const wantHooks = !process.argv.includes("--no-hooks");
120
121function log(msg) {
122 process.stderr.write(`lchat-dev-up: ${msg}\n`);
123}
124
125async function postJSON(url, body, headers = { "Content-Type": "application/json" }) {
126 const res = await fetch(url, { method: "POST", headers, body: JSON.stringify(body) });
127 if (!res.ok) {
128 const detail = await res.text().catch(() => "");
129 throw new Error(`POST ${url} -> HTTP ${res.status} ${detail}`.trim());
130 }
131 return res.json();
132}
133
134function freePort() {
135 return new Promise((resolve, reject) => {
136 const srv = net.createServer();
137 srv.on("error", reject);
138 srv.listen(0, "127.0.0.1", () => {
139 const { port } = srv.address();
140 srv.close(() => resolve(port));
141 });
142 });
143}
144
145async function waitForServer(base, server) {
146 const deadline = Date.now() + 10_000;
147 while (Date.now() < deadline) {
148 if (server.exitCode !== null) throw new Error(`server exited early (code ${server.exitCode})`);
149 try {
150 if ((await fetch(`${base}/api/version`)).ok) return;
151 } catch {}
152 await new Promise((r) => setTimeout(r, 100));
153 }
154 throw new Error("server didn't become ready in time");
155}
156
157// Provisions an admin-granting setup invite for onboarding mode (fresh instance,
158// no buddies or chats). The first person to claim it becomes admin and lands in
159// the setup conversation with Eleven.
160async function seedOnboarding(base, adminToken) {
161 const admin = { "Content-Type": "application/json", Authorization: `Bearer ${adminToken}` };
162
163 const T = randomSecret();
164 const derived = await deriveInvite(T);
165 await postJSON(
166 `${base}/api/admin/key-invites`,
167 {
168 id: derived.id,
169 wrapped: "",
170 claim_hash: derived.claimHash,
171 grants_admin: true,
172 },
173 admin,
174 );
175
176 return {
177 inviteId: derived.id,
178 inviteURL: `${base}/join/join#${buildInviteFragment(T, "")}`,
179 };
180}
181
182// Provisions "Dev Buddy" (a real, fully-joined member, so it can post) and a
183// group thread containing them + a brand-new "you" account, then posts a
184// welcome message. Returns what the caller needs to build the invite link, patch
185// its thread_id, and persist "you" for re-issuing the link on later runs.
186async function seed(base, adminToken) {
187 const admin = { "Content-Type": "application/json", Authorization: `Bearer ${adminToken}` };
188
189 const buddyT = randomSecret();
190 const buddyDerived = await deriveInvite(buddyT);
191 const buddyCreate = await postJSON(
192 `${base}/api/admin/key-invites`,
193 { label: BUDDY_NAME, id: buddyDerived.id, wrapped: "", claim_hash: buddyDerived.claimHash },
194 admin,
195 );
196 const buddyClaim = await postJSON(`${base}/api/key-invite/${encodeURIComponent(buddyDerived.id)}/claim`, {
197 claim_secret: buddyDerived.claimSecret,
198 });
199 const buddyKp = await generateUserKeypair();
200 await postJSON(`${base}/api/pubkey`, { token: buddyClaim.token, public_key: buddyKp.publicKey });
201
202 // "You": generate the keypair ourselves and ship it wrapped in the invite —
203 // the same shape shell.js's addDevice() builds — so opening the link signs
204 // the browser straight in as this already-provisioned member. We keep the
205 // keypair in the config so later runs can re-issue a fresh add-device link
206 // for the same account without re-seeding. grants_admin makes dev-you an
207 // ADMIN member (belt: patchAdminRole braces it) — the in-app admin surfaces
208 // (Admin, Webhooks) all work without the console token.
209 const youKp = await generateUserKeypair();
210 const youT = randomSecret();
211 const youDerived = await deriveInvite(youT);
212 const youWrapped = await encrypt(youDerived.wrapKey, { pub: youKp.publicKey, priv: youKp.privateKey });
213 const youCreate = await postJSON(
214 `${base}/api/admin/key-invites`,
215 { id: youDerived.id, wrapped: youWrapped, claim_hash: youDerived.claimHash, grants_admin: true },
216 admin,
217 );
218
219 const group = await generateKey();
220 const members = [
221 { user_id: buddyCreate.user_id, wrapped: await wrapThreadKey(group.b64, buddyKp.publicKey) },
222 { user_id: youCreate.user_id, wrapped: await wrapThreadKey(group.b64, youKp.publicKey) },
223 ];
224 const nameBlob = await encrypt(group.key, { name: GROUP_NAME });
225 const thread = await postJSON(`${base}/api/threads`, {
226 token: buddyClaim.token,
227 kind: "group",
228 name_blob: nameBlob,
229 members,
230 });
231
232 const welcome = await encrypt(group.key, {
233 name: BUDDY_NAME,
234 text: `👋 Welcome to ${GROUP_NAME} — this chat was seeded by lchat-dev-up.`,
235 ts: Date.now(),
236 uid: buddyCreate.user_id,
237 });
238 await postJSON(`${base}/api/messages?token=${encodeURIComponent(buddyClaim.token)}`, {
239 thread_id: thread.thread_id,
240 blob: welcome,
241 });
242
243 return {
244 threadId: thread.thread_id,
245 inviteId: youDerived.id,
246 inviteURL: `${base}/join/join#${buildInviteFragment(youT, "You")}`,
247 you: { userId: youCreate.user_id, publicKey: youKp.publicKey, privateKey: youKp.privateKey },
248 };
249}
250
251// Re-mint a fresh add-device invite for an already-seeded "you" account, using
252// the keypair we stashed in the config on the fresh seed. Passing the existing
253// user_id makes the server re-issue for that member rather than create a new one
254// (see createKeyInvite). Returns the new invite id + URL.
255//
256// Deliberately NOT thread-patched: claimKeyInvite refuses a thread-bound
257// invite once its user already has a device (the anti-account-takeover guard
258// on user invites), and a reused instance's "you" always has one — a patched
259// reissue would make the printed link die with "This invite was just used."
260// The pre-open nicety only works on the very first, fresh-seed claim; a
261// reissued link signs you in and leaves the group one tap away.
262async function reissueYouInvite(base, adminToken, cfg) {
263 const admin = { "Content-Type": "application/json", Authorization: `Bearer ${adminToken}` };
264 const youT = randomSecret();
265 const derived = await deriveInvite(youT);
266 const wrapped = await encrypt(derived.wrapKey, { pub: cfg.you.publicKey, priv: cfg.you.privateKey });
267 await postJSON(
268 `${base}/api/admin/key-invites`,
269 { id: derived.id, wrapped, claim_hash: derived.claimHash, user_id: cfg.you.userId },
270 admin,
271 );
272 return {
273 inviteId: derived.id,
274 inviteURL: `${base}/join/join#${buildInviteFragment(youT, "You")}`,
275 };
276}
277
278// Best-effort: point the "you" invite at the group thread so join.js opens it
279// automatically on sign-in (see web/static/join.js's finish(), which stores
280// claim.thread_id as lchat_open_thread). No admin endpoint sets thread_id on
281// an existing invite, so this reaches into the sqlite file directly — safe
282// here only because it's a throwaway dev DB we created moments ago.
283function patchInviteThread(dbPath, inviteId, threadId) {
284 if (!SAFE_ID.test(inviteId) || !SAFE_ID.test(threadId)) {
285 log("unexpected id shape — skipping thread pre-open");
286 return;
287 }
288 try {
289 execFileSync("sqlite3", [dbPath, `UPDATE key_invites SET thread_id = '${threadId}' WHERE id = '${inviteId}';`]);
290 } catch (e) {
291 log(`couldn't pre-open the group thread (${e.message}) — you'll land on the thread list instead`);
292 }
293}
294
295// Best-effort, idempotent: make dev-you an admin MEMBER (role, not the console
296// token) — the in-app admin surfaces (Admin, Webhooks) gate on it. Fresh seeds
297// get it via the invite's grants_admin; this patch also promotes a "you" from
298// an instance seeded before that existed. Same throwaway-dev-DB caveat as
299// patchInviteThread.
300function patchAdminRole(dbPath, userId) {
301 if (!userId || !SAFE_ID.test(userId)) return;
302 try {
303 execFileSync("sqlite3", [dbPath, `UPDATE users SET role = 'admin' WHERE id = '${userId}';`]);
304 } catch (e) {
305 log(`couldn't grant the admin role (${e.message}) — in-app admin surfaces won't show`);
306 }
307}
308
309// Mint the hooks service-identity invite (only when no session is stored — the
310// sidecar persists its identity like the dev bot does) and spawn
311// `lchat hooks run` on its own local TCP port: the same binary, the real
312// provision → poll → encrypt → post path, just with no router in front. The
313// server is told the port via LCHAT_HOOKS_BASE so the Webhooks app displays
314// URLs that actually work on this machine.
315async function startHooksSidecar(binPath, base, adminToken, hooksPort) {
316 let invite = "";
317 if (!existsSync(HOOKS_SESSION_PATH)) {
318 const T = randomSecret();
319 const derived = await deriveInvite(T);
320 await postJSON(
321 `${base}/api/admin/key-invites`,
322 { label: "Webhooks", id: derived.id, wrapped: "", claim_hash: derived.claimHash, agent: true, hooks_service: true },
323 { "Content-Type": "application/json", Authorization: `Bearer ${adminToken}` },
324 );
325 invite = T;
326 log("minted a hooks service invite (the sidecar claims it on first boot)");
327 }
328 const child = spawn(binPath, ["hooks", "run"], {
329 env: {
330 ...process.env,
331 LCHAT_BASE: base,
332 LCHAT_SESSION: HOOKS_SESSION_PATH,
333 LCHAT_INVITE: invite,
334 LCHAT_SOCKET: "", // never inherit a socket path from the ambient env
335 ADDR: `127.0.0.1:${hooksPort}`,
336 },
337 });
338 child.stdout.pipe(process.stderr);
339 child.stderr.pipe(process.stderr);
340 const deadline = Date.now() + 10_000;
341 while (Date.now() < deadline) {
342 if (child.exitCode !== null) throw new Error(`hooks sidecar exited early (code ${child.exitCode})`);
343 try {
344 if ((await fetch(`http://127.0.0.1:${hooksPort}/healthz`)).ok) return child;
345 } catch {}
346 await new Promise((r) => setTimeout(r, 100));
347 }
348 child.kill();
349 throw new Error("hooks sidecar didn't become ready in time");
350}
351
352async function main() {
353 if (isClean) {
354 const stale = readConfig();
355 const staleDb = stale?.dbPath || DEFAULT_DB_PATH;
356 for (const f of dbFiles(staleDb)) rmSync(f, { force: true });
357 clearConfig();
358 clearHooksSession();
359 log(`cleaned ${path.basename(CONFIG_PATH)} + its db — starting fresh`);
360 }
361
362 const existing = readConfig();
363 const dbPath = existing?.dbPath || DEFAULT_DB_PATH;
364 // Reuse only when the config AND the db it points at both still exist; a
365 // config whose db was deleted out from under it can't be trusted to describe
366 // the (now-empty) instance, so we re-seed.
367 const reusing = !!existing && existsSync(dbPath);
368 if (existing && !reusing) log("config found but its db is gone — starting fresh");
369 // A fresh instance invalidates the dev bot's stored identity (its user won't
370 // exist in the new db), so clear it — lchat-dev-bot will re-provision.
371 if (!reusing && existsSync(BOT_SESSION_PATH)) {
372 clearBotSession();
373 log("fresh instance — cleared the stored dev-bot session (it'll re-provision)");
374 }
375 // Same for the hooks sidecar's service identity.
376 if (!reusing && existsSync(HOOKS_SESSION_PATH)) {
377 clearHooksSession();
378 log("fresh instance — cleared the stored hooks-sidecar session (it'll re-provision)");
379 }
380
381 const onboarding = reusing ? existing.mode === "onboarding" : isOnboarding;
382 const adminToken = reusing ? existing.adminToken : randomBytes(16).toString("hex");
383 const port = explicitPort ? Number(explicitPort) : reusing ? existing.port : await freePort();
384 const base = `http://127.0.0.1:${port}`;
385 // The hooks sidecar's port is as sticky as the server's, so webhook URLs
386 // already handed to outside senders keep working across restarts.
387 const hooksPort = wantHooks ? (reusing && existing.hooksPort) || (await freePort()) : null;
388
389 const buildDir = mkdtempSync(path.join(tmpdir(), "lchat-dev-"));
390 const binPath = path.join(buildDir, "lchat");
391
392 let cleaned = false;
393 let server;
394 let hooksSidecar;
395 const cleanup = () => {
396 if (cleaned) return;
397 cleaned = true;
398 hooksSidecar?.kill();
399 server?.kill();
400 // Keep the db + config on purpose — that's what makes restarts reuse them.
401 rmSync(buildDir, { recursive: true, force: true });
402 };
403 process.on("SIGINT", () => { cleanup(); process.exit(0); });
404 process.on("SIGTERM", () => { cleanup(); process.exit(0); });
405 process.on("exit", cleanup);
406
407 log("building lchat from this checkout...");
408 execFileSync("go", ["build", "-o", binPath, "."], { cwd: REPO_ROOT, stdio: "inherit" });
409
410 log(`starting server on ${base} (db ${path.basename(dbPath)})...`);
411 server = spawn(binPath, [], {
412 cwd: buildDir,
413 env: {
414 ...process.env,
415 LCHAT_DB: dbPath,
416 LCHAT_ADMIN_TOKEN: adminToken,
417 ADDR: `127.0.0.1:${port}`,
418 // With the sidecar on a bare local port (no router, no in.<host> DNS),
419 // the server tells clients the real ingest base so the Webhooks app
420 // displays URLs that work on this machine.
421 ...(hooksPort ? { LCHAT_HOOKS_BASE: `http://127.0.0.1:${hooksPort}` } : {}),
422 },
423 });
424 server.stdout.pipe(process.stderr);
425 server.stderr.pipe(process.stderr);
426
427 await waitForServer(base, server);
428
429 // Build the config we'll persist. On reuse we carry the seeded fields forward
430 // and only refresh the connection details (port/base can change via --port).
431 const cfg = reusing
432 ? { ...existing, base, port, adminToken, dbPath, mode: onboarding ? "onboarding" : "group" }
433 : { version: 1, mode: onboarding ? "onboarding" : "group", base, port, adminToken, dbPath };
434 cfg.adminURL = `${base}/admin#token=${adminToken}`;
435
436 let inviteType;
437 if (reusing) {
438 if (onboarding) {
439 // Onboarding's setup invite carries no keypair (the browser generates the
440 // admin's keys on claim), so there's nothing to re-issue. The stable port
441 // keeps your browser signed in; a fresh browser needs --clean.
442 log("reusing existing onboarding instance...");
443 cfg.inviteURL = null;
444 cfg.inviteId = null;
445 inviteType = "SETUP (already claimed — reload your browser)";
446 } else {
447 log("reusing existing instance — re-minting a sign-in link for you...");
448 const re = await reissueYouInvite(base, adminToken, existing);
449 cfg.inviteId = re.inviteId;
450 cfg.inviteURL = re.inviteURL;
451 inviteType = "Regular (user invite)";
452 }
453 } else if (onboarding) {
454 log("seeding fresh onboarding instance (setup/admin invite, no buddies or chats)...");
455 const seeded = await seedOnboarding(base, adminToken);
456 cfg.inviteId = seeded.inviteId;
457 cfg.inviteURL = seeded.inviteURL;
458 inviteType = "SETUP (admin-granting)";
459 } else {
460 log("seeding a second member + group chat...");
461 const seeded = await seed(base, adminToken);
462 patchInviteThread(dbPath, seeded.inviteId, seeded.threadId);
463 cfg.threadId = seeded.threadId;
464 cfg.you = seeded.you;
465 cfg.buddyName = BUDDY_NAME;
466 cfg.groupName = GROUP_NAME;
467 cfg.inviteId = seeded.inviteId;
468 cfg.inviteURL = seeded.inviteURL;
469 inviteType = "Regular (user invite)";
470 }
471
472 // Dev-you is an admin member (fresh seeds via the invite's grants_admin;
473 // this idempotent patch also promotes a "you" seeded before that existed),
474 // so the in-app admin surfaces — Admin, Webhooks — work out of the box.
475 if (cfg.you?.userId) patchAdminRole(dbPath, cfg.you.userId);
476
477 if (hooksPort) cfg.hooksPort = hooksPort;
478 writeConfig(cfg);
479
480 if (hooksPort) {
481 try {
482 hooksSidecar = await startHooksSidecar(binPath, base, adminToken, hooksPort);
483 } catch (e) {
484 // The chat server is still useful without ingest — degrade, loudly.
485 log(`hooks sidecar failed to start (${e.message}) — continuing without webhook ingest`);
486 }
487 }
488
489 console.log("");
490 console.log(`Server: ${base} ${reusing ? "(reused)" : "(fresh)"}`);
491 console.log(`Admin: ${cfg.adminURL}`);
492 console.log(`Config: ${CONFIG_PATH} (lchat-dev-bot reads this)`);
493 if (hooksSidecar) {
494 console.log(`Webhooks: http://127.0.0.1:${hooksPort} (ingest — create one in the app: Apps → Webhooks)`);
495 }
496 console.log(`Type: ${inviteType}`);
497 if (cfg.inviteURL) {
498 console.log(`Invite ID: ${cfg.inviteId}`);
499 console.log(`Link: ${cfg.inviteURL}`);
500 }
501 console.log("");
502 if (onboarding && reusing) {
503 console.log("🔁 Reusing this instance — reload your browser; the stable port keeps you signed in.");
504 console.log(" Fresh browser or want a clean slate? re-run with --clean.");
505 } else if (onboarding) {
506 console.log("📋 Open the invite link to become admin and start onboarding.");
507 console.log(" The /admin command will be available in chat after you join.");
508 } else if (reusing) {
509 console.log("💬 Open the invite link to sign in — the group with Dev Buddy is one tap away.");
510 console.log(" (Already signed in from before? Just reload — the link is only if you need it.)");
511 } else {
512 console.log("💬 Open the invite link to land straight in the group chat with Dev Buddy.");
513 }
514 console.log("");
515 if (hooksSidecar) {
516 console.log("🪝 Webhook round-trip: open Apps → Webhooks, create one (it deploys within ~10s),");
517 console.log(" then: curl -X POST -d '{\"hello\":\"world\"}' '<the URL the app shows>'");
518 console.log(" The event lands encrypted in the chosen thread (typed events render only once");
519 console.log(" an app claims them — check the sidecar log lines above to watch delivery).");
520 }
521 console.log("Ctrl-C stops the server; the db + config are kept (use --clean to wipe them).");
522 console.log("");
523
524 await new Promise((resolve) => server.on("exit", resolve));
525}
526
527main().catch((e) => {
528 log(e.message);
529 process.exit(1);
530});
531