import { randomBytes, randomUUID } from 'node:crypto'; import type { DatabaseSync } from 'node:sqlite'; import { AgreementRow, ContractRevisionRow, MessageRow, ParticipantRow, QuestionRow, RoomRow, Store, } from './store.js'; import { LIMITS } from './limits.js'; import { availableActions, computeAdvice, computeTurn } from './advice.js'; import { CreateRoomInput, CreatedRoom, MessageView, ObserverView, ParticipantView, QuestionView, RendezvousError, RoomView, } from './types.js'; function nowIso(): string { return new Date().toISOString(); } function newId(prefix: string): string { return `${prefix}_${randomUUID().replace(/-/g, '').slice(0, 20)}`; } /** Cryptographically random invite token; the token IS the authorization. */ export function newToken(): string { return randomBytes(24).toString('base64url'); // 32 chars, 192 bits } export class RendezvousService { private db: DatabaseSync; constructor(readonly store: Store) { this.db = store.db; } // ---------- room lifecycle ---------- createRoom(input: CreateRoomInput, baseUrl: string): CreatedRoom { const title = (input.title ?? '').trim(); if (!title) throw new RendezvousError('validation', 'title is required'); if (Buffer.byteLength(title) > 2000) { throw new RendezvousError('validation', 'title too long (max 2000 bytes)'); } const ps = input.participants ?? []; if (ps.length < LIMITS.minParticipants) { throw new RendezvousError('validation', `at least ${LIMITS.minParticipants} participants required`); } if (ps.length > LIMITS.maxParticipants) { throw new RendezvousError('validation', `at most ${LIMITS.maxParticipants} participants allowed`); } const roles = new Set(); for (const p of ps) { const role = (p.role ?? '').trim(); if (!role) throw new RendezvousError('validation', 'each participant needs a role'); if (Buffer.byteLength(role) > 200) { throw new RendezvousError('validation', 'participant role too long'); } if (roles.has(role)) { throw new RendezvousError('validation', `duplicate participant role: ${role}`); } roles.add(role); } const ttl = input.ttl_hours ?? LIMITS.defaultTtlHours; if (!Number.isFinite(ttl) || ttl <= 0 || ttl > LIMITS.maxTtlHours) { throw new RendezvousError('validation', `ttl_hours must be between 1 and ${LIMITS.maxTtlHours}`); } const roomId = randomBytes(9).toString('base64url'); // 12 chars const createdAt = nowIso(); const expiresAt = new Date(Date.now() + ttl * 3600_000).toISOString(); const created: { id: string; role: string; token: string }[] = []; const insertRoom = this.db.prepare( `INSERT INTO rooms (id, title, brief, goal, status, created_at, expires_at, observer_token) VALUES (?, ?, ?, ?, 'open', ?, ?, ?)`, ); const insertParticipant = this.db.prepare( `INSERT INTO participants (id, room_id, role, display_name, token, knows, needs_to_determine, instructions, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, ); const observerToken = newToken(); this.db.exec('BEGIN'); try { insertRoom.run(roomId, title, input.brief ?? '', input.goal ?? '', createdAt, expiresAt, observerToken); for (const p of ps) { const pid = newId('prt'); const token = newToken(); insertParticipant.run( pid, roomId, p.role.trim(), (p.display_name ?? p.role).trim().slice(0, 200), token, JSON.stringify((p.knows ?? []).slice(0, 50).map((s) => String(s).slice(0, 1000))), JSON.stringify((p.needs_to_determine ?? []).slice(0, 50).map((s) => String(s).slice(0, 1000))), (p.instructions ?? '').slice(0, 20000), createdAt, ); created.push({ id: pid, role: p.role.trim(), token }); } this.db.exec('COMMIT'); } catch (e) { this.db.exec('ROLLBACK'); throw e; } return { room_id: roomId, status: 'open', expires_at: expiresAt, participants: created, invite_urls: created.map((p) => `${baseUrl}/r/${roomId}/${p.token}`), observer_url: `${baseUrl}/o/${roomId}/${observerToken}`, }; } // ---------- auth ---------- authenticate(roomId: string, token: string): { room: RoomRow; participant: ParticipantRow } { const room = this.getRoomRow(roomId); const row = this.db .prepare('SELECT * FROM participants WHERE room_id = ? AND token = ?') .get(roomId, token) as ParticipantRow | undefined; if (!row) { throw new RendezvousError('forbidden', 'invalid token for this room'); } return { room, participant: row }; } authenticateByToken(token: string): { room: RoomRow; participant: ParticipantRow } { const row = this.db.prepare('SELECT * FROM participants WHERE token = ?').get(token) as | ParticipantRow | undefined; if (!row) throw new RendezvousError('not_found', 'unknown token'); const room = this.getRoomRow(row.room_id); return { room, participant: row }; } private getRoomRow(roomId: string): RoomRow { const room = this.db.prepare('SELECT * FROM rooms WHERE id = ?').get(roomId) as RoomRow | undefined; if (!room) throw new RendezvousError('not_found', 'room not found'); return room; } private ensureOpen(room: RoomRow): void { if (new Date(room.expires_at).getTime() < Date.now()) { this.cleanupExpired(); throw new RendezvousError('not_found', 'room not found'); } if (room.status !== 'open') { throw new RendezvousError('conflict', `room status is '${room.status}'; no further changes allowed`); } } // ---------- reads ---------- listParticipants(roomId: string): ParticipantView[] { return (this.db .prepare('SELECT * FROM participants WHERE room_id = ? ORDER BY created_at, id') .all(roomId) as unknown as ParticipantRow[]).map((p) => this.toParticipantView(p)); } private toParticipantView(p: ParticipantRow): ParticipantView { return { id: p.id, role: p.role, display_name: p.display_name, knows: JSON.parse(p.knows) as string[], needs_to_determine: JSON.parse(p.needs_to_determine) as string[], instructions: p.instructions, }; } listMessages(roomId: string): MessageView[] { const rows = this.db .prepare( `SELECT m.*, p.role FROM messages m JOIN participants p ON p.id = m.participant_id WHERE m.room_id = ? ORDER BY m.created_at, m.id`, ) .all(roomId) as unknown as (MessageRow & { role: string })[]; return rows.map((m) => ({ id: m.id, participant_id: m.participant_id, role: m.role, content: m.content, created_at: m.created_at, })); } listQuestions(roomId: string): QuestionView[] { const rows = this.db .prepare( `SELECT q.*, pa.role AS author_role, pt.role AS to_role FROM questions q JOIN participants pa ON pa.id = q.participant_id LEFT JOIN participants pt ON pt.id = q.addressed_to WHERE q.room_id = ? ORDER BY q.created_at, q.id`, ) .all(roomId) as unknown as (QuestionRow & { author_role: string; to_role: string | null })[]; return rows.map((q) => ({ id: q.id, participant_id: q.participant_id, author_role: q.author_role, addressed_to: q.addressed_to, addressed_to_role: q.to_role, question: q.question, blocking: q.blocking === 1, status: q.status, resolution: q.resolution, created_at: q.created_at, resolved_at: q.resolved_at, })); } getRoomView(roomId: string, token: string): RoomView { const { room, participant } = this.authenticate(roomId, token); const participants = this.listParticipants(roomId); const conversation = this.listMessages(roomId); const questions = this.listQuestions(roomId); const openQuestions = questions.filter((q) => q.status === 'open'); const resolvedQuestions = questions.filter((q) => q.status === 'resolved'); const agreements = this.db .prepare( `SELECT a.participant_id, a.contract_version, p.role FROM agreements a JOIN participants p ON p.id = a.participant_id WHERE a.room_id = ?`, ) .all(roomId) as { participant_id: string; contract_version: number; role: string }[]; const contract = room.contract_version > 0 ? { version: room.contract_version, markdown: room.contract_markdown, updated_at: room.contract_updated_at, agreements: agreements.map((a) => ({ participant_id: a.participant_id, role: a.role, version: a.contract_version, })), } : null; const mine = agreements.find((a) => a.participant_id === participant.id); const status = room.status as 'open' | 'agreed' | 'expired'; const viewStatus = new Date(room.expires_at).getTime() < Date.now() ? 'expired' : status; return { room: { id: room.id, title: room.title, brief: room.brief, goal: room.goal, status, created_at: room.created_at, expires_at: room.expires_at, }, your_role: participant.role, your_participant_id: participant.id, participants, conversation, open_questions: openQuestions, resolved_questions: resolvedQuestions, current_contract: contract, room_status: viewStatus, what_you_should_do_next: computeAdvice({ status: viewStatus, you: this.toParticipantView(participant), participants, openQuestions, resolvedQuestions, conversation, contract, yourAgreedVersion: mine ? mine.contract_version : null, }), available_actions: availableActions(viewStatus), }; } /** Read-only view for the human observer: room state + whose turn it is. */ getObserverView(roomId: string, observerToken: string): ObserverView { const room = this.getRoomRow(roomId); if (room.observer_token !== observerToken) { throw new RendezvousError('forbidden', 'invalid observer token'); } const participants = this.listParticipants(roomId); const conversation = this.listMessages(roomId); const questions = this.listQuestions(roomId); const openQuestions = questions.filter((q) => q.status === 'open'); const resolvedQuestions = questions.filter((q) => q.status === 'resolved'); const agreements = this.db .prepare( `SELECT a.participant_id, a.contract_version FROM agreements a JOIN participants p ON p.id = a.participant_id WHERE a.room_id = ?`, ) .all(roomId) as { participant_id: string; contract_version: number }[]; const contract = room.contract_version > 0 ? { version: room.contract_version, markdown: room.contract_markdown, updated_at: room.contract_updated_at, agreements: agreements.map((a) => ({ participant_id: a.participant_id, role: participants.find((p) => p.id === a.participant_id)?.role ?? '?', version: a.contract_version, })), } : null; const status = room.status as 'open' | 'agreed' | 'expired'; const viewStatus = new Date(room.expires_at).getTime() < Date.now() ? 'expired' : status; const turn = computeTurn(viewStatus, participants, openQuestions, contract); return { room: { id: room.id, title: room.title, brief: room.brief, goal: room.goal, status, created_at: room.created_at, expires_at: room.expires_at, }, participants, conversation, open_questions: openQuestions, resolved_questions: resolvedQuestions, current_contract: contract, room_status: viewStatus, turn: turn.text, turn_waiting_for: turn.waiting_for, }; } // ---------- writes ---------- postMessage(roomId: string, token: string, content: string): MessageView { const { room, participant } = this.authenticate(roomId, token); this.ensureOpen(room); const body = typeof content === 'string' ? content : ''; if (!body.trim()) throw new RendezvousError('validation', 'content is required'); if (Buffer.byteLength(body) > LIMITS.maxMessageBytes) { throw new RendezvousError('limit', `message too large (max ${LIMITS.maxMessageBytes} bytes)`); } const count = this.db.prepare('SELECT COUNT(*) AS c FROM messages WHERE room_id = ?').get(roomId) as { c: number; }; if (count.c >= LIMITS.maxMessagesPerRoom) { throw new RendezvousError('limit', `message limit reached (${LIMITS.maxMessagesPerRoom})`); } const totalRow = this.db .prepare( `SELECT (SELECT COALESCE(SUM(LENGTH(content)),0) FROM messages WHERE room_id = ?) + (SELECT COALESCE(SUM(LENGTH(question) + LENGTH(resolution)),0) FROM questions WHERE room_id = ?) + (SELECT COALESCE(SUM(LENGTH(markdown)),0) FROM contract_revisions WHERE room_id = ?) AS s`, ) .get(roomId, roomId, roomId) as { s: number }; if (totalRow.s + Buffer.byteLength(body) > LIMITS.maxRoomTotalBytes) { throw new RendezvousError('limit', 'room total size limit reached'); } const id = newId('msg'); this.db .prepare('INSERT INTO messages (id, room_id, participant_id, content, created_at) VALUES (?, ?, ?, ?, ?)') .run(id, roomId, participant.id, body, nowIso()); return { id, participant_id: participant.id, role: participant.role, content: body, created_at: nowIso(), }; } openQuestion( roomId: string, token: string, question: string, blocking: boolean, addressedToParticipantId?: string | null, ): QuestionView { const { room, participant } = this.authenticate(roomId, token); this.ensureOpen(room); const body = typeof question === 'string' ? question.trim() : ''; if (!body) throw new RendezvousError('validation', 'question is required'); if (Buffer.byteLength(body) > LIMITS.maxQuestionBytes) { throw new RendezvousError('limit', `question too large (max ${LIMITS.maxQuestionBytes} bytes)`); } let addressedTo: string | null = null; if (addressedToParticipantId) { const target = this.db .prepare('SELECT id FROM participants WHERE id = ? AND room_id = ?') .get(addressedToParticipantId, roomId); if (!target) throw new RendezvousError('validation', 'addressed_to_participant_id not found in this room'); if (addressedToParticipantId === participant.id) { throw new RendezvousError('validation', 'cannot address a question to yourself'); } addressedTo = addressedToParticipantId; } const count = this.db.prepare('SELECT COUNT(*) AS c FROM questions WHERE room_id = ?').get(roomId) as { c: number; }; if (count.c >= LIMITS.maxQuestionsPerRoom) { throw new RendezvousError('limit', `question limit reached (${LIMITS.maxQuestionsPerRoom})`); } const id = newId('q'); this.db .prepare( `INSERT INTO questions (id, room_id, participant_id, addressed_to, question, blocking, status, created_at) VALUES (?, ?, ?, ?, ?, ?, 'open', ?)`, ) .run(id, roomId, participant.id, addressedTo, body, blocking ? 1 : 0, nowIso()); return { id, participant_id: participant.id, author_role: participant.role, addressed_to: addressedTo, addressed_to_role: addressedTo ? ((this.db.prepare('SELECT role FROM participants WHERE id = ?').get(addressedTo) as { role: string }).role) : null, question: body, blocking, status: 'open', resolution: null, created_at: nowIso(), resolved_at: null, }; } resolveQuestion(roomId: string, token: string, questionId: string, resolution: string): QuestionView { const { room, participant } = this.authenticate(roomId, token); this.ensureOpen(room); const q = this.db .prepare('SELECT * FROM questions WHERE id = ? AND room_id = ?') .get(questionId, roomId) as QuestionRow | undefined; if (!q) throw new RendezvousError('not_found', 'question not found'); if (q.status === 'resolved') { throw new RendezvousError('conflict', 'question is already resolved'); } const body = typeof resolution === 'string' ? resolution.trim() : ''; if (!body) throw new RendezvousError('validation', 'resolution text is required'); if (Buffer.byteLength(body) > LIMITS.maxQuestionBytes) { throw new RendezvousError('limit', 'resolution too large'); } // The addressee or the author may resolve; anyone may resolve a question // addressed to "anyone" only if they are the addressee in practice. Keep // it simple: the addressee (or author) resolves. const isAddressee = q.addressed_to === participant.id; const isAuthor = q.participant_id === participant.id; if (!isAddressee && !isAuthor) { throw new RendezvousError('forbidden', 'only the addressee or the author can resolve a question'); } this.db .prepare( `UPDATE questions SET status = 'resolved', resolution = ?, resolved_by = ?, resolved_at = ? WHERE id = ?`, ) .run(body, participant.id, nowIso(), questionId); return this.listQuestions(roomId).find((x) => x.id === questionId)!; } proposeContract(roomId: string, token: string, markdown: string): { version: number; markdown: string } { const { room, participant } = this.authenticate(roomId, token); this.ensureOpen(room); const body = typeof markdown === 'string' ? markdown : ''; if (!body.trim()) throw new RendezvousError('validation', 'markdown is required'); if (Buffer.byteLength(body) > LIMITS.maxContractBytes) { throw new RendezvousError('limit', `contract too large (max ${LIMITS.maxContractBytes} bytes)`); } const version = room.contract_version + 1; this.db.exec('BEGIN'); try { this.db .prepare( `INSERT INTO contract_revisions (id, room_id, version, markdown, proposed_by, created_at) VALUES (?, ?, ?, ?, ?, ?)`, ) .run(newId('rev'), roomId, version, body, participant.id, nowIso()); this.db .prepare('UPDATE rooms SET contract_markdown = ?, contract_version = ?, contract_updated_at = ? WHERE id = ?') .run(body, version, nowIso(), roomId); this.db.exec('COMMIT'); } catch (e) { this.db.exec('ROLLBACK'); throw e; } return { version, markdown: body }; } listContractRevisions(roomId: string): ContractRevisionRow[] { return this.db .prepare('SELECT * FROM contract_revisions WHERE room_id = ? ORDER BY version') .all(roomId) as unknown as ContractRevisionRow[]; } agree(roomId: string, token: string): { agreed_contract_version: number; room_status: string; everyone_agreed: boolean; } { const { room, participant } = this.authenticate(roomId, token); this.ensureOpen(room); if (room.contract_version === 0 || room.contract_markdown.trim() === '') { throw new RendezvousError('conflict', 'no contract has been proposed yet'); } this.db .prepare( `INSERT INTO agreements (room_id, participant_id, contract_version, created_at) VALUES (?, ?, ?, ?) ON CONFLICT(room_id, participant_id) DO UPDATE SET contract_version = excluded.contract_version, created_at = excluded.created_at`, ) .run(roomId, participant.id, room.contract_version, nowIso()); const participants = this.listParticipants(roomId); const agreements = this.db .prepare('SELECT participant_id, contract_version FROM agreements WHERE room_id = ?') .all(roomId) as { participant_id: string; contract_version: number }[]; const everyone = participants.every((p) => { const a = agreements.find((x) => x.participant_id === p.id); return a && a.contract_version === room.contract_version; }); const blocking = this.db .prepare(`SELECT COUNT(*) AS c FROM questions WHERE room_id = ? AND status = 'open' AND blocking = 1`) .get(roomId) as { c: number }; if (everyone && blocking.c === 0) { this.db.prepare(`UPDATE rooms SET status = 'agreed' WHERE id = ?`).run(roomId); return { agreed_contract_version: room.contract_version, room_status: 'agreed', everyone_agreed: true }; } return { agreed_contract_version: room.contract_version, room_status: 'open', everyone_agreed: everyone, }; } // ---------- destroy ---------- /** * Immediately and irreversibly delete a room. Allowed for any room member: * a participant (invite token) or the observer (observer token). */ destroyRoom(roomId: string, token: string): void { const room = this.getRoomRow(roomId); const isObserver = room.observer_token !== null && room.observer_token === token; const isParticipant = !!this.db.prepare('SELECT id FROM participants WHERE room_id = ? AND token = ?').get(roomId, token); if (!isObserver && !isParticipant) { throw new RendezvousError('forbidden', 'invalid token for this room'); } this.db.prepare('DELETE FROM rooms WHERE id = ?').run(roomId); // cascade wipes everything } // ---------- TTL cleanup ---------- /** Deletes everything belonging to expired rooms. Returns number of rooms removed. */ cleanupExpired(): number { const expired = this.db .prepare(`SELECT id FROM rooms WHERE expires_at < ? AND status != 'expired'`) .all(nowIso()) as { id: string }[]; if (expired.length === 0) return 0; const delRoom = this.db.prepare('DELETE FROM rooms WHERE id = ?'); this.db.exec('BEGIN'); try { for (const r of expired) delRoom.run(r.id); // ON DELETE CASCADE removes the rest this.db.exec('COMMIT'); } catch (e) { this.db.exec('ROLLBACK'); throw e; } return expired.length; } startCleanupTimer(intervalMs = 10 * 60_000): NodeJS.Timeout { return setInterval(() => { try { this.cleanupExpired(); } catch (e) { console.error('[cleanup] failed:', e); } }, intervalMs); } close(): void { this.store.close(); } }