/** * SkateSpots Community — always-on Cordn room bot. * * The room itself is founder-owned; the bot is a normal MLS member that joins * once and then approves new join requests. It never creates a group (that * would risk a split if its state were lost): if there is no local group it * publishes a key package and asks to join, then waits for an admin's Welcome. * * Secrets: COMMUNITY_BOT_KEY (hex) comes from a 600 env file. The MLS snapshot * on disk also contains group private material, so keep the state file 600 and * back it up. * * Run locally: * COMMUNITY_BOT_KEY=... COMMUNITY_GID=... npx tsx scripts/community-bot.ts * * Env: * COMMUNITY_BOT_KEY 32-byte hex (required) * COMMUNITY_GID e.g. 35006::community (required) * COMMUNITY_STATE snapshot path (default /var/lib/skatespots-community/state.json) * COMMUNITY_COORDINATOR coordinator pubkey (default SkateSpots') * COMMUNITY_RELAY coordinator relay (default wss://relay.skate-spots.org) * COMMUNITY_POLL_MS poll interval (default 15000) * COMMUNITY_AUTO_APPROVE=0 leave requests for admins instead of adding * COMMUNITY_ONCE=1 one cycle then exit (for a dry run) */ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; import { dirname } from 'node:path'; import WebSocket from 'ws'; import { CordnSession, type CordnSnapshot, type CordnStore } from '../src/lib/cordn/native/session'; // Node 20 (the relay box) has no global WebSocket. The ContextVM transport // looks for one at connect time, so install `ws` before opening a session. (globalThis as unknown as { WebSocket?: unknown }).WebSocket = WebSocket; const COORDINATOR_PUBKEY = process.env.COMMUNITY_COORDINATOR ?? '02816a05b1d4e70a7df9c712ef6f7272774269d65bd8a2f262ce0330d0885693'; const RELAY = process.env.COMMUNITY_RELAY ?? 'wss://relay.skate-spots.org'; const GID = process.env.COMMUNITY_GID ?? ''; const BOT_KEY = (process.env.COMMUNITY_BOT_KEY ?? '').trim().toLowerCase(); const STATE_PATH = process.env.COMMUNITY_STATE ?? '/var/lib/skatespots-community/state.json'; const POLL_MS = Number(process.env.COMMUNITY_POLL_MS ?? 15_000); const AUTO_APPROVE = process.env.COMMUNITY_AUTO_APPROVE !== '0'; const CREATE = process.env.COMMUNITY_CREATE === '1'; const ONCE = process.env.COMMUNITY_ONCE === '1'; if (!/^[0-9a-f]{64}$/.test(BOT_KEY)) { console.error('[community] COMMUNITY_BOT_KEY must be 64 lowercase hex chars'); process.exit(1); } if (!GID) { console.error('[community] COMMUNITY_GID is required'); process.exit(1); } const started = Date.now(); const log = (...parts: unknown[]) => { const uptime = Math.round((Date.now() - started) / 1000); console.log(`[community +${uptime}s]`, ...parts); }; const short = (pk: string) => `${pk.slice(0, 8)}…`; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); /** JSON snapshot store. Atomic writes, 600, and no duplicated bot private key. */ class FileCordnStore implements CordnStore { constructor(private readonly path: string) {} async load(): Promise { try { return JSON.parse(await readFile(this.path, 'utf8')) as CordnSnapshot; } catch { return null; } } async save(snapshot: CordnSnapshot): Promise { await mkdir(dirname(this.path), { recursive: true, mode: 0o700 }); // The bot key lives in the env file; don't duplicate it in the state. const { privateKeyHex, ...safe } = snapshot; void privateKeyHex; const tmp = `${this.path}.${process.pid}.tmp`; await writeFile(tmp, JSON.stringify(safe), { mode: 0o600 }); await rename(tmp, this.path); } async clear(): Promise { await writeFile(this.path, 'null', { mode: 0o600 }); } } let session!: CordnSession; const hasRoom = () => session.listGroups().some((group) => group.gid === GID); async function joinRoom(): Promise { if (hasRoom()) return true; // A Welcome already waiting? Accept it before asking again, so a restart // between approval and pickup does not create a duplicate request. const welcomes = await session.fetchWelcomes(); for (const welcome of welcomes) { try { const joined = await session.acceptWelcome(welcome, 'SkateSpots Community'); if (joined.gid === GID) { log('joined the room as a member'); return true; } } catch { // Welcome for a key package this bot no longer has — skip. } } // Ask the founder/admin to add us. `requestJoin` publishes a key package if // one is not already waiting. await session.requestJoin(GID); lastJoinRequestAt = Date.now(); log('join request sent; waiting for an admin to approve this bot'); return false; } const approved = new Set(); const lastAttempt = new Map(); let lastJoinRequestAt = 0; let lastError = ''; async function cycle(): Promise { try { if (!hasRoom()) { if (CREATE) { await session.createGroup({ groupId: GID, name: 'SkateSpots Community' }); log('created the room (bootstrap); set COMMUNITY_CREATE=0 and restart'); } const joined = await joinRoom(); if (!joined) { // Retry the request every 5 minutes; a fresh key package may be needed // if the original one was consumed or expired. if (Date.now() - lastJoinRequestAt > 5 * 60_000) { await session.requestJoin(GID); lastJoinRequestAt = Date.now(); log('join request refreshed'); } return; } } // Keep the bot's MLS state current with commits from other members. await session.fetch(GID); if (!AUTO_APPROVE) return; const requests = await session.fetchJoinRequests(GID); for (const request of requests.slice(0, 10)) { if (request.pk === session.pubkey) continue; // our own boot request if (approved.has(request.pk)) continue; const last = lastAttempt.get(request.pk) ?? 0; if (Date.now() - last < 5 * 60_000) continue; try { await session.addMemberByIdentifier(GID, request.pk); approved.add(request.pk); log('approved member', short(request.pk)); await sleep(1500); // be gentle with the coordinator } catch (error) { lastAttempt.set(request.pk, Date.now()); log('could not approve', short(request.pk), error instanceof Error ? error.message : String(error)); await sleep(1500); } } } catch (error) { const message = error instanceof Error ? error.message : String(error); if (message !== lastError) { lastError = message; log('cycle error', message); } } } let stopping = false; async function stop(signal: string): Promise { if (stopping) return; stopping = true; log(`stopping (${signal})`); try { if (session) await session.disconnect(); } finally { process.exit(0); } } process.on('SIGINT', () => void stop('SIGINT')); process.on('SIGTERM', () => void stop('SIGTERM')); async function main(): Promise { session = await CordnSession.open({ coordinator: { serverPubkey: COORDINATOR_PUBKEY, relays: [RELAY] }, privateKeyHex: BOT_KEY, store: new FileCordnStore(STATE_PATH), }); log('bot pubkey', session.pubkey, 'room', GID); do { await cycle(); if (ONCE) break; await sleep(POLL_MS); } while (!stopping); // `ONCE` is used for setup/dry runs and tests; close the ContextVM client so // the process can exit instead of hanging on the WebSocket. if (ONCE) await session.disconnect(); } void main().catch((error) => { console.error('[community] fatal', error instanceof Error ? error.message : String(error)); process.exit(1); });