// Provisioning: turn an admin invite into a persisted agent session. // // This is web/static/join.js run headlessly. Mint a fresh-person invite in the // admin console (ideally the "This is an agent" one so it gets a bot badge) and // pass its #fragment as LCHAT_INVITE. The fragment never reaches the server: we // derive the invite id from it, generate our own keypair, claim a device token, // and register the public key in the roster. // // Exposed as provision(T, {name, path}) so the runtime (agent.mjs) can // self-provision each bot identity on first boot in a container; also runnable // directly: `node provision.mjs` (provisions the primary bot). import { writeFileSync } from "node:fs"; import { deriveInvite, generateUserKeypair } from "../web/static/crypto.js"; import { BASE, AGENT_NAME, SESSION_PATH, requireBase, loadSession } from "./config.mjs"; // provision claims the invite secret T, generates a keypair, registers the // pubkey, and persists + returns the session. Throws on any failure. export async function provision(T, { name = AGENT_NAME, path = SESSION_PATH } = {}) { const post = (p, body) => fetch(`${BASE}${p}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), }).then((r) => r.json()); const { id, claimSecret } = await deriveInvite(T); const info = await (await fetch(`${BASE}/api/key-invite/${encodeURIComponent(id)}`)).json(); if (!info.ok) throw new Error("invite is used or invalid — mint a fresh one"); if (info.wrapped) throw new Error("that's an add-device link; use a fresh-person invite"); const kp = await generateUserKeypair(); // The claim must prove knowledge of the invite secret (claim_secret is the // preimage of the stored claim_hash). The screen name is never sent — an agent's // label was set on the server when the admin minted the invite. (Mirrors join.js.) const claim = await post(`/api/key-invite/${encodeURIComponent(id)}/claim`, { claim_secret: claimSecret }); if (!claim.ok || !claim.token) throw new Error("couldn't claim the invite (it may have just been used)"); await post("/api/pubkey", { token: claim.token, public_key: kp.publicKey }); const session = { token: claim.token, name, userPub: kp.publicKey, userPriv: kp.privateKey }; writeFileSync(path, JSON.stringify(session, null, 2)); return session; } // CLI: `node provision.mjs` with LCHAT_INVITE in the environment. if (import.meta.url === `file://${process.argv[1]}`) { requireBase(); if (loadSession()) { console.error(`Already provisioned (${SESSION_PATH}). Delete it to re-provision.`); process.exit(1); } const T = process.env.LCHAT_INVITE; if (!T) { console.error("Set LCHAT_INVITE to the #fragment of a /join/... link from the admin console."); process.exit(1); } try { await provision(T); console.log(`Provisioned "${AGENT_NAME}". Session written to ${SESSION_PATH}.`); console.log("Now add the agent to a group, or start a DM with it from the app, then run: npm start"); } catch (e) { console.error(e.message); process.exit(1); } }