| 1 | // Provisioning: turn an admin invite into a persisted agent session. |
| 2 | // |
| 3 | // This is web/static/join.js run headlessly. Mint a fresh-person invite in the |
| 4 | // admin console (ideally the "This is an agent" one so it gets a bot badge) and |
| 5 | // pass its #fragment as LCHAT_INVITE. The fragment never reaches the server: we |
| 6 | // derive the invite id from it, generate our own keypair, claim a device token, |
| 7 | // and register the public key in the roster. |
| 8 | // |
| 9 | // Exposed as provision(T, {name, path}) so the runtime (agent.mjs) can |
| 10 | // self-provision each bot identity on first boot in a container; also runnable |
| 11 | // directly: `node provision.mjs` (provisions the primary bot). |
| 12 | import { writeFileSync } from "node:fs"; |
| 13 | import { deriveInvite, generateUserKeypair } from "../web/static/crypto.js"; |
| 14 | import { BASE, AGENT_NAME, SESSION_PATH, requireBase, loadSession } from "./config.mjs"; |
| 15 | |
| 16 | // provision claims the invite secret T, generates a keypair, registers the |
| 17 | // pubkey, and persists + returns the session. Throws on any failure. |
| 18 | export async function provision(T, { name = AGENT_NAME, path = SESSION_PATH } = {}) { |
| 19 | const post = (p, body) => |
| 20 | fetch(`${BASE}${p}`, { |
| 21 | method: "POST", |
| 22 | headers: { "content-type": "application/json" }, |
| 23 | body: JSON.stringify(body), |
| 24 | }).then((r) => r.json()); |
| 25 | |
| 26 | const { id, claimSecret } = await deriveInvite(T); |
| 27 | const info = await (await fetch(`${BASE}/api/key-invite/${encodeURIComponent(id)}`)).json(); |
| 28 | if (!info.ok) throw new Error("invite is used or invalid — mint a fresh one"); |
| 29 | if (info.wrapped) throw new Error("that's an add-device link; use a fresh-person invite"); |
| 30 | |
| 31 | const kp = await generateUserKeypair(); |
| 32 | // The claim must prove knowledge of the invite secret (claim_secret is the |
| 33 | // preimage of the stored claim_hash). The screen name is never sent — an agent's |
| 34 | // label was set on the server when the admin minted the invite. (Mirrors join.js.) |
| 35 | const claim = await post(`/api/key-invite/${encodeURIComponent(id)}/claim`, { claim_secret: claimSecret }); |
| 36 | if (!claim.ok || !claim.token) throw new Error("couldn't claim the invite (it may have just been used)"); |
| 37 | await post("/api/pubkey", { token: claim.token, public_key: kp.publicKey }); |
| 38 | |
| 39 | const session = { token: claim.token, name, userPub: kp.publicKey, userPriv: kp.privateKey }; |
| 40 | writeFileSync(path, JSON.stringify(session, null, 2)); |
| 41 | return session; |
| 42 | } |
| 43 | |
| 44 | // CLI: `node provision.mjs` with LCHAT_INVITE in the environment. |
| 45 | if (import.meta.url === `file://${process.argv[1]}`) { |
| 46 | requireBase(); |
| 47 | if (loadSession()) { |
| 48 | console.error(`Already provisioned (${SESSION_PATH}). Delete it to re-provision.`); |
| 49 | process.exit(1); |
| 50 | } |
| 51 | const T = process.env.LCHAT_INVITE; |
| 52 | if (!T) { |
| 53 | console.error("Set LCHAT_INVITE to the #fragment of a /join/... link from the admin console."); |
| 54 | process.exit(1); |
| 55 | } |
| 56 | try { |
| 57 | await provision(T); |
| 58 | console.log(`Provisioned "${AGENT_NAME}". Session written to ${SESSION_PATH}.`); |
| 59 | console.log("Now add the agent to a group, or start a DM with it from the app, then run: npm start"); |
| 60 | } catch (e) { |
| 61 | console.error(e.message); |
| 62 | process.exit(1); |
| 63 | } |
| 64 | } |
| 65 | |