AI Rendezvous MVP: core + HTTP API + SQLite, agent Markdown endpoints, web UI, MCP server
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@ai-rendezvous/core",
|
||||
"version": "0.1.0",
|
||||
"description": "Domain core: rooms, participants, messages, open questions, agreed contract. Knows nothing about any AI harness or model.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"main": "dist/src/index.js",
|
||||
"types": "dist/src/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc -b"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { MessageView, ParticipantView, QuestionView, ContractView } from './types.js';
|
||||
|
||||
export interface AdviceInput {
|
||||
status: 'open' | 'agreed' | 'expired';
|
||||
you: ParticipantView;
|
||||
participants: ParticipantView[];
|
||||
openQuestions: QuestionView[];
|
||||
resolvedQuestions: QuestionView[];
|
||||
conversation: MessageView[];
|
||||
contract: ContractView | null;
|
||||
yourAgreedVersion: number | null;
|
||||
}
|
||||
|
||||
function others(list: ParticipantView[], youId: string): string {
|
||||
const names = list.filter((p) => p.id !== youId).map((p) => p.role);
|
||||
return names.join(', ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the `what_you_should_do_next` hint so an agent does not have to
|
||||
* reconstruct negotiation state from the full chat log.
|
||||
*/
|
||||
export function computeAdvice(input: AdviceInput): string {
|
||||
const { status, you, openQuestions, contract, yourAgreedVersion } = input;
|
||||
|
||||
if (status === 'expired') {
|
||||
return 'This room has expired. All data has been or will be deleted. Create a new rendezvous if needed.';
|
||||
}
|
||||
if (status === 'agreed') {
|
||||
return 'The contract is agreed by all participants and the room is finalized. Download the final artifact with GET /api/rooms/{room_id}/final.md (Authorization: Bearer <your token>) while the room still exists.';
|
||||
}
|
||||
|
||||
const addressedToYou = openQuestions.filter(
|
||||
(q) => q.addressed_to === null || q.addressed_to === you.id,
|
||||
);
|
||||
const blockingToYou = addressedToYou.filter((q) => q.blocking);
|
||||
const blockingFromYou = openQuestions.filter(
|
||||
(q) => q.participant_id === you.id && q.blocking,
|
||||
);
|
||||
|
||||
if (blockingToYou.length > 0) {
|
||||
const first = blockingToYou[0];
|
||||
return (
|
||||
`There ${blockingToYou.length === 1 ? 'is 1 blocking question' : `are ${blockingToYou.length} blocking questions`} waiting for you. ` +
|
||||
`First: "${first.question}" (asked by ${first.author_role}${first.addressed_to_role ? ` specifically of ${first.addressed_to_role}` : ''}). ` +
|
||||
'Verify the relevant facts on your side, post your answer as a message, then resolve the question with POST /api/rooms/{room_id}/questions/{question_id}/resolve including the verified facts in the resolution.'
|
||||
);
|
||||
}
|
||||
|
||||
if (blockingFromYou.length > 0) {
|
||||
return (
|
||||
`You have ${blockingFromYou.length === 1 ? 'an open blocking question' : `${blockingFromYou.length} open blocking questions`} that ${others(input.participants, you.id)} must answer. ` +
|
||||
'While waiting, verify the facts you already claimed, keep your answers ready, and do not finalize until these are resolved.'
|
||||
);
|
||||
}
|
||||
|
||||
const nonBlocking = openQuestions.filter((q) => !q.blocking);
|
||||
if (nonBlocking.length > 0) {
|
||||
return (
|
||||
`No blocking questions are waiting on you, but ${nonBlocking.length} non-blocking ${nonBlocking.length === 1 ? 'question is' : 'questions are'} still open. ` +
|
||||
'Answer or resolve them if you can, then move the contract forward.'
|
||||
);
|
||||
}
|
||||
|
||||
if (!contract || contract.version === 0 || contract.markdown.trim() === '') {
|
||||
return (
|
||||
'All questions are resolved and no contract has been drafted yet. ' +
|
||||
`Draft the Agreed Contract now (sections: Facts, Decisions, Interface, Schedule, Authentication, Error handling, Unresolved) using PUT /api/rooms/{room_id}/contract, ` +
|
||||
'basing it only on verified facts from the conversation.'
|
||||
);
|
||||
}
|
||||
|
||||
if (yourAgreedVersion !== contract.version) {
|
||||
return (
|
||||
`Contract version ${contract.version} is proposed but you have not agreed to it yet. ` +
|
||||
`Review it against your side of reality: if it is correct, POST /api/rooms/{room_id}/agree; if not, propose a revision with PUT /api/rooms/{room_id}/contract and open a blocking question explaining the disagreement.`
|
||||
);
|
||||
}
|
||||
|
||||
const notAgreed = contract.agreements.filter((a) => a.version !== contract.version);
|
||||
if (notAgreed.length > 0) {
|
||||
return (
|
||||
`You agreed to contract version ${contract.version}; waiting for ${notAgreed.map((a) => a.role).join(', ')} to agree to the same version. ` +
|
||||
'You can continue the discussion with messages or questions if something on your side changed.'
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
'All blocking questions are resolved and every participant agreed to the current contract version. ' +
|
||||
'Finalize the room with POST /api/rooms/{room_id}/agree (or /finalize) to set status=agreed, then fetch the final Markdown artifact.'
|
||||
);
|
||||
}
|
||||
|
||||
export function availableActions(status: string): string[] {
|
||||
if (status !== 'open') {
|
||||
return ['GET /api/rooms/{room_id} (read)', 'GET /api/rooms/{room_id}/final.md (download artifact)'];
|
||||
}
|
||||
return [
|
||||
'POST /api/rooms/{room_id}/messages — post a message',
|
||||
'POST /api/rooms/{room_id}/questions — open a question {question, blocking, addressed_to_participant_id?}',
|
||||
'POST /api/rooms/{room_id}/questions/{question_id}/resolve — answer/resolve a question {resolution}',
|
||||
'PUT /api/rooms/{room_id}/contract — propose/update the Agreed Contract {markdown}',
|
||||
'POST /api/rooms/{room_id}/agree — agree to current contract version / finalize',
|
||||
'GET /api/rooms/{room_id}/final.md — final Markdown artifact',
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { Store } from './store.js';
|
||||
export { RendezvousService, newToken } from './service.js';
|
||||
export { LIMITS } from './limits.js';
|
||||
export { computeAdvice, availableActions } from './advice.js';
|
||||
export { renderRoomMarkdown, renderFinalMarkdown } from './markdown.js';
|
||||
export * from './types.js';
|
||||
@@ -0,0 +1,14 @@
|
||||
// Hard limits for a safe public deployment.
|
||||
|
||||
export const LIMITS = {
|
||||
maxParticipants: 8,
|
||||
minParticipants: 2,
|
||||
maxMessagesPerRoom: 500,
|
||||
maxQuestionsPerRoom: 200,
|
||||
maxMessageBytes: 32 * 1024,
|
||||
maxQuestionBytes: 8 * 1024,
|
||||
maxContractBytes: 128 * 1024,
|
||||
maxRoomTotalBytes: 4 * 1024 * 1024,
|
||||
maxTtlHours: 24,
|
||||
defaultTtlHours: 24,
|
||||
} as const;
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { MessageView, ParticipantView, QuestionView, RoomView } from './types.js';
|
||||
|
||||
export interface MarkdownInput {
|
||||
baseUrl: string;
|
||||
roomId: string;
|
||||
title: string;
|
||||
goal: string;
|
||||
brief: string;
|
||||
status: string;
|
||||
expiresAt: string;
|
||||
participants: ParticipantView[];
|
||||
conversation: MessageView[];
|
||||
openQuestions: QuestionView[];
|
||||
resolvedQuestions: QuestionView[];
|
||||
contractMarkdown: string;
|
||||
contractVersion: number;
|
||||
}
|
||||
|
||||
export function renderRoomMarkdown(i: MarkdownInput): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
parts.push(`# AI Rendezvous: ${i.title}`);
|
||||
parts.push('');
|
||||
parts.push('You are a participant in an AI Rendezvous room — a temporary neutral meeting place for existing AI sessions on different machines, harnesses and providers. The server is transport-only state coordination; it never calls any model. Negotiate by exchanging messages and open questions, verify facts on your side, converge on the Agreed Contract. The room is deleted automatically.');
|
||||
parts.push('');
|
||||
parts.push(`- **Goal:** ${i.goal || '(not set)'}`);
|
||||
if (i.brief) parts.push(`- **Brief:** ${i.brief}`);
|
||||
parts.push(`- **Status:** ${i.status}`);
|
||||
parts.push(`- **Expires:** ${i.expiresAt} (all data is deleted then)`);
|
||||
parts.push('');
|
||||
|
||||
parts.push('## Participants');
|
||||
for (const p of i.participants) {
|
||||
parts.push(`### ${p.role}${p.display_name && p.display_name !== p.role ? ` (${p.display_name})` : ''}`);
|
||||
if (p.knows.length) parts.push(`- **knows:** ${p.knows.join('; ')}`);
|
||||
if (p.needs_to_determine.length) parts.push(`- **needs to determine:** ${p.needs_to_determine.join('; ')}`);
|
||||
if (p.instructions) parts.push(`- **instructions:** ${p.instructions}`);
|
||||
parts.push('');
|
||||
}
|
||||
|
||||
parts.push('## Open questions');
|
||||
if (i.openQuestions.length === 0) {
|
||||
parts.push('(none)');
|
||||
} else {
|
||||
for (const q of i.openQuestions) {
|
||||
const to = q.addressed_to_role ? ` → ${q.addressed_to_role}` : '';
|
||||
parts.push(`- [${q.blocking ? 'BLOCKING' : 'non-blocking'}] (id: ${q.id}) ${q.author_role}${to}: ${q.question}`);
|
||||
}
|
||||
}
|
||||
parts.push('');
|
||||
|
||||
if (i.resolvedQuestions.length) {
|
||||
parts.push('## Resolved questions');
|
||||
for (const q of i.resolvedQuestions) {
|
||||
parts.push(`- (id: ${q.id}) ${q.author_role}: ${q.question}`);
|
||||
parts.push(` - resolution (${q.resolved_at ?? ''}): ${q.resolution ?? ''}`);
|
||||
}
|
||||
parts.push('');
|
||||
}
|
||||
|
||||
parts.push('## Current Agreed Contract');
|
||||
parts.push(i.contractMarkdown.trim() ? i.contractMarkdown : `(not drafted yet, version ${i.contractVersion})`);
|
||||
parts.push('');
|
||||
|
||||
parts.push('## Conversation');
|
||||
if (i.conversation.length === 0) {
|
||||
parts.push('(no messages yet)');
|
||||
} else {
|
||||
for (const m of i.conversation) {
|
||||
parts.push(`**${m.role}** (${m.created_at}, id: ${m.id}):`);
|
||||
parts.push('');
|
||||
parts.push(m.content);
|
||||
parts.push('');
|
||||
}
|
||||
}
|
||||
|
||||
parts.push('## Available API actions');
|
||||
parts.push(`Base URL: ${i.baseUrl}. Authenticate with \`Authorization: Bearer <your invite token>\` or \`?token=<token>\`.`);
|
||||
parts.push('- `GET /api/rooms/' + i.roomId + '` — full room state as JSON (your_role, room_goal, conversation, open_questions, current_contract, room_status, what_you_should_do_next)');
|
||||
parts.push('- `POST /api/rooms/' + i.roomId + '/messages` — {"content": "..."}');
|
||||
parts.push('- `POST /api/rooms/' + i.roomId + '/questions` — {"question": "...", "blocking": true, "addressed_to_participant_id": "..."}');
|
||||
parts.push('- `POST /api/rooms/' + i.roomId + '/questions/{question_id}/resolve` — {"resolution": "verified facts..."}');
|
||||
parts.push('- `PUT /api/rooms/' + i.roomId + '/contract` — {"markdown": "## Facts\\n..."}');
|
||||
parts.push('- `POST /api/rooms/' + i.roomId + '/agree` — agree to current contract version; finalizes when everyone agreed and no blocking questions remain');
|
||||
parts.push('- `GET /api/rooms/' + i.roomId + '/final.md` — final Markdown artifact');
|
||||
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
export function renderFinalMarkdown(v: RoomView): string {
|
||||
const r = v.room;
|
||||
const parts: string[] = [];
|
||||
parts.push(`# Agreed Contract — ${r.title}`);
|
||||
parts.push('');
|
||||
parts.push(`- **Goal:** ${r.goal || '(not set)'}`);
|
||||
parts.push(`- **Status:** ${r.status}`);
|
||||
parts.push(`- **Agreed at:** ${new Date().toISOString()}`);
|
||||
parts.push(`- **Participants:** ${v.participants.map((p) => p.role).join(', ')}`);
|
||||
if (r.brief) parts.push(`- **Brief:** ${r.brief}`);
|
||||
parts.push('');
|
||||
|
||||
const contract = v.current_contract;
|
||||
if (contract && contract.markdown.trim()) {
|
||||
parts.push(contract.markdown);
|
||||
} else {
|
||||
parts.push('(no contract was recorded)');
|
||||
}
|
||||
parts.push('');
|
||||
|
||||
if (v.resolved_questions.length) {
|
||||
parts.push('## Verified facts (resolved questions)');
|
||||
for (const q of v.resolved_questions) {
|
||||
parts.push(`- **Q (${q.author_role}):** ${q.question}`);
|
||||
parts.push(` **A:** ${q.resolution ?? '(no resolution text)'}`);
|
||||
}
|
||||
parts.push('');
|
||||
}
|
||||
|
||||
parts.push('---');
|
||||
parts.push(`_Artifact of AI Rendezvous room ${r.id}. Room expires at ${r.expires_at}, after which all data is deleted._`);
|
||||
return parts.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
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 } from './advice.js';
|
||||
import {
|
||||
CreateRoomInput,
|
||||
CreatedRoom,
|
||||
MessageView,
|
||||
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<string>();
|
||||
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) 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 (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
);
|
||||
|
||||
this.db.exec('BEGIN');
|
||||
try {
|
||||
insertRoom.run(roomId, title, input.brief ?? '', input.goal ?? '', createdAt, expiresAt);
|
||||
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}`),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- 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),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- 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,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
export interface RoomRow {
|
||||
id: string;
|
||||
title: string;
|
||||
brief: string;
|
||||
goal: string;
|
||||
status: 'open' | 'agreed' | 'expired';
|
||||
created_at: string;
|
||||
expires_at: string;
|
||||
contract_markdown: string;
|
||||
contract_version: number;
|
||||
contract_updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface ParticipantRow {
|
||||
id: string;
|
||||
room_id: string;
|
||||
role: string;
|
||||
display_name: string;
|
||||
token: string;
|
||||
knows: string; // JSON array
|
||||
needs_to_determine: string; // JSON array
|
||||
instructions: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface MessageRow {
|
||||
id: string;
|
||||
room_id: string;
|
||||
participant_id: string;
|
||||
content: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface QuestionRow {
|
||||
id: string;
|
||||
room_id: string;
|
||||
participant_id: string; // author
|
||||
addressed_to: string | null; // participant id or null (anyone)
|
||||
question: string;
|
||||
blocking: 0 | 1;
|
||||
status: 'open' | 'resolved';
|
||||
resolution: string | null;
|
||||
resolved_by: string | null;
|
||||
created_at: string;
|
||||
resolved_at: string | null;
|
||||
}
|
||||
|
||||
export interface ContractRevisionRow {
|
||||
id: string;
|
||||
room_id: string;
|
||||
version: number;
|
||||
markdown: string;
|
||||
proposed_by: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AgreementRow {
|
||||
room_id: string;
|
||||
participant_id: string;
|
||||
contract_version: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const SCHEMA = `
|
||||
CREATE TABLE IF NOT EXISTS rooms (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
brief TEXT NOT NULL DEFAULT '',
|
||||
goal TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
contract_markdown TEXT NOT NULL DEFAULT '',
|
||||
contract_version INTEGER NOT NULL DEFAULT 0,
|
||||
contract_updated_at TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS participants (
|
||||
id TEXT PRIMARY KEY,
|
||||
room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
knows TEXT NOT NULL DEFAULT '[]',
|
||||
needs_to_determine TEXT NOT NULL DEFAULT '[]',
|
||||
instructions TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
|
||||
participant_id TEXT NOT NULL REFERENCES participants(id) ON DELETE CASCADE,
|
||||
content TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_room ON messages(room_id, created_at);
|
||||
CREATE TABLE IF NOT EXISTS questions (
|
||||
id TEXT PRIMARY KEY,
|
||||
room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
|
||||
participant_id TEXT NOT NULL REFERENCES participants(id) ON DELETE CASCADE,
|
||||
addressed_to TEXT,
|
||||
question TEXT NOT NULL,
|
||||
blocking INTEGER NOT NULL DEFAULT 1,
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
resolution TEXT,
|
||||
resolved_by TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
resolved_at TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_questions_room ON questions(room_id, status);
|
||||
CREATE TABLE IF NOT EXISTS contract_revisions (
|
||||
id TEXT PRIMARY KEY,
|
||||
room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
|
||||
version INTEGER NOT NULL,
|
||||
markdown TEXT NOT NULL,
|
||||
proposed_by TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS agreements (
|
||||
room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
|
||||
participant_id TEXT NOT NULL REFERENCES participants(id) ON DELETE CASCADE,
|
||||
contract_version INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (room_id, participant_id)
|
||||
);
|
||||
`;
|
||||
|
||||
export class Store {
|
||||
readonly db: DatabaseSync;
|
||||
|
||||
constructor(path: string) {
|
||||
this.db = new DatabaseSync(path);
|
||||
this.db.exec('PRAGMA journal_mode = WAL');
|
||||
this.db.exec('PRAGMA foreign_keys = ON');
|
||||
this.db.exec(SCHEMA);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.db.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// Domain types. The core deliberately knows nothing about specific AI
|
||||
// harnesses, models or providers: only Room / Participant / Message /
|
||||
// OpenQuestion / AgreedContract.
|
||||
|
||||
export interface ParticipantInput {
|
||||
role: string;
|
||||
display_name?: string;
|
||||
knows?: string[];
|
||||
needs_to_determine?: string[];
|
||||
instructions?: string;
|
||||
}
|
||||
|
||||
export interface CreateRoomInput {
|
||||
title: string;
|
||||
brief?: string;
|
||||
goal?: string;
|
||||
/** Hours until the room is deleted. Max 24, default 24. */
|
||||
ttl_hours?: number;
|
||||
participants: ParticipantInput[];
|
||||
}
|
||||
|
||||
export interface ParticipantView {
|
||||
id: string;
|
||||
role: string;
|
||||
display_name: string;
|
||||
knows: string[];
|
||||
needs_to_determine: string[];
|
||||
instructions: string;
|
||||
}
|
||||
|
||||
export interface MessageView {
|
||||
id: string;
|
||||
participant_id: string;
|
||||
role: string;
|
||||
content: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface QuestionView {
|
||||
id: string;
|
||||
participant_id: string;
|
||||
author_role: string;
|
||||
addressed_to: string | null; // participant id
|
||||
addressed_to_role: string | null;
|
||||
question: string;
|
||||
blocking: boolean;
|
||||
status: 'open' | 'resolved';
|
||||
resolution: string | null;
|
||||
created_at: string;
|
||||
resolved_at: string | null;
|
||||
}
|
||||
|
||||
export interface ContractView {
|
||||
version: number;
|
||||
markdown: string;
|
||||
updated_at: string | null;
|
||||
agreements: { participant_id: string; role: string; version: number }[];
|
||||
}
|
||||
|
||||
export interface RoomView {
|
||||
room: {
|
||||
id: string;
|
||||
title: string;
|
||||
brief: string;
|
||||
goal: string;
|
||||
status: 'open' | 'agreed' | 'expired';
|
||||
created_at: string;
|
||||
expires_at: string;
|
||||
};
|
||||
your_role: string;
|
||||
your_participant_id: string;
|
||||
participants: ParticipantView[];
|
||||
conversation: MessageView[];
|
||||
open_questions: QuestionView[];
|
||||
resolved_questions: QuestionView[];
|
||||
current_contract: ContractView | null;
|
||||
room_status: 'open' | 'agreed' | 'expired';
|
||||
what_you_should_do_next: string;
|
||||
available_actions: string[];
|
||||
}
|
||||
|
||||
export interface CreatedRoom {
|
||||
room_id: string;
|
||||
status: string;
|
||||
expires_at: string;
|
||||
invite_urls: string[]; // same order as input participants
|
||||
participants: { id: string; role: string; token: string }[];
|
||||
}
|
||||
|
||||
export class RendezvousError extends Error {
|
||||
constructor(
|
||||
readonly code:
|
||||
| 'not_found'
|
||||
| 'forbidden'
|
||||
| 'validation'
|
||||
| 'limit'
|
||||
| 'conflict',
|
||||
message: string,
|
||||
readonly status: number = code === 'not_found' ? 404
|
||||
: code === 'forbidden' ? 403
|
||||
: code === 'validation' ? 400
|
||||
: code === 'limit' ? 429
|
||||
: 409,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { RendezvousService, Store, RendezvousError } from '../src/index.js';
|
||||
|
||||
function makeService(): { svc: RendezvousService; dbPath: string } {
|
||||
const dbPath = `:memory:`;
|
||||
const store = new Store(dbPath);
|
||||
return { svc: new RendezvousService(store), dbPath };
|
||||
}
|
||||
|
||||
function createTwoPartyRoom(svc: RendezvousService) {
|
||||
return svc.createRoom(
|
||||
{
|
||||
title: '1C <-> app integration',
|
||||
goal: 'Agree on exchange contract',
|
||||
participants: [
|
||||
{ role: 'windows-1c', knows: ['IIS', '1C'], needs_to_determine: ['frequency'] },
|
||||
{ role: 'application', knows: ['app code'], needs_to_determine: ['contract'] },
|
||||
],
|
||||
},
|
||||
'http://test.local',
|
||||
);
|
||||
}
|
||||
|
||||
test('createRoom returns invite urls and tokens for each participant', () => {
|
||||
const { svc } = makeService();
|
||||
const created = createTwoPartyRoom(svc);
|
||||
assert.equal(created.status, 'open');
|
||||
assert.equal(created.invite_urls.length, 2);
|
||||
assert.match(created.invite_urls[0], /http:\/\/test\.local\/r\/.+\/.+/);
|
||||
assert.notEqual(created.participants[0].token, created.participants[1].token);
|
||||
assert.ok(created.participants[0].token.length >= 32, 'token must be long and random');
|
||||
});
|
||||
|
||||
test('token of participant A cannot act as participant B context and vice versa works', () => {
|
||||
const { svc } = makeService();
|
||||
const created = createTwoPartyRoom(svc);
|
||||
const roomId = created.room_id;
|
||||
const tokenA = created.participants[0].token;
|
||||
const tokenB = created.participants[1].token;
|
||||
|
||||
const viewA = svc.getRoomView(roomId, tokenA);
|
||||
assert.equal(viewA.your_role, 'windows-1c');
|
||||
const viewB = svc.getRoomView(roomId, tokenB);
|
||||
assert.equal(viewB.your_role, 'application');
|
||||
|
||||
// wrong room id with a valid token -> forbidden/not found
|
||||
assert.throws(() => svc.getRoomView('nosuchroom', tokenA), RendezvousError);
|
||||
});
|
||||
|
||||
test('participant tokens are isolated: a token from another room is rejected', () => {
|
||||
const { svc } = makeService();
|
||||
const room1 = createTwoPartyRoom(svc);
|
||||
const room2 = svc.createRoom(
|
||||
{ title: 'other', participants: [{ role: 'x' }, { role: 'y' }] },
|
||||
'http://test.local',
|
||||
);
|
||||
assert.throws(() => svc.getRoomView(room2.room_id, room1.participants[0].token), RendezvousError);
|
||||
});
|
||||
|
||||
test('invalid token rejected, room id alone is not enough', () => {
|
||||
const { svc } = makeService();
|
||||
const created = createTwoPartyRoom(svc);
|
||||
assert.throws(() => svc.authenticate(created.room_id, 'forged-token-123'), RendezvousError);
|
||||
});
|
||||
|
||||
test('full negotiation scenario: messages -> questions -> contract -> agree -> final.md', () => {
|
||||
const { svc } = makeService();
|
||||
const created = createTwoPartyRoom(svc);
|
||||
const roomId = created.room_id;
|
||||
const [, B] = created.participants.map((p) => p.token);
|
||||
|
||||
// A states facts
|
||||
svc.postMessage(roomId, created.participants[0].token, 'IIS has /exchange/hs endpoint, auth is basic.');
|
||||
|
||||
// B asks a blocking question addressed to A
|
||||
const q = svc.openQuestion(roomId, B, 'What is the real exchange frequency? Check the 1C job config.', true, created.participants[0].id);
|
||||
assert.equal(q.status, 'open');
|
||||
assert.equal(q.blocking, true);
|
||||
|
||||
// A cannot finalize while blocking question open
|
||||
svc.proposeContract(roomId, created.participants[0].token, '## Facts\ntodo');
|
||||
const agreeWhileOpen = svc.agree(roomId, B);
|
||||
assert.equal(agreeWhileOpen.room_status, 'open');
|
||||
|
||||
// A verifies and resolves
|
||||
svc.postMessage(roomId, created.participants[0].token, 'Checked regagent job: every 15 minutes.');
|
||||
svc.resolveQuestion(roomId, created.participants[0].token, q.id, 'Verified in 1C job config: every 15 min.');
|
||||
|
||||
// B proposes final contract, both agree -> agreed
|
||||
svc.proposeContract(roomId, B, '## Facts\n- every 15 min\n## Interface\n- POST /exchange/hs');
|
||||
svc.agree(roomId, B);
|
||||
const final = svc.agree(roomId, created.participants[0].token);
|
||||
assert.equal(final.room_status, 'agreed');
|
||||
|
||||
const view = svc.getRoomView(roomId, B);
|
||||
assert.equal(view.room_status, 'agreed');
|
||||
assert.ok(view.what_you_should_do_next.includes('final'));
|
||||
|
||||
// room is closed for writes
|
||||
assert.throws(() => svc.postMessage(roomId, B, 'late message'), (e: RendezvousError) => e.code === 'conflict');
|
||||
});
|
||||
|
||||
test('TTL cleanup deletes rooms, messages, tokens and artifacts', () => {
|
||||
const { svc } = makeService();
|
||||
const created = createTwoPartyRoom(svc);
|
||||
const roomId = created.room_id;
|
||||
const [, B] = created.participants.map((p) => p.token);
|
||||
svc.postMessage(roomId, B, 'hello');
|
||||
const q = svc.openQuestion(roomId, B, 'q?', true, created.participants[0].id);
|
||||
svc.proposeContract(roomId, B, '## Facts\n- x');
|
||||
|
||||
// force-expire the room directly in storage
|
||||
svc.store.db
|
||||
.prepare('UPDATE rooms SET expires_at = ? WHERE id = ?')
|
||||
.run(new Date(Date.now() - 1000).toISOString(), roomId);
|
||||
|
||||
const removed = svc.cleanupExpired();
|
||||
assert.equal(removed, 1);
|
||||
|
||||
const count = (table: string) =>
|
||||
(svc.store.db.prepare(`SELECT COUNT(*) AS c FROM ${table} WHERE room_id = ?`).get(roomId) as { c: number }).c;
|
||||
assert.equal((svc.store.db.prepare('SELECT COUNT(*) AS c FROM rooms').get() as { c: number }).c, 0);
|
||||
assert.equal(count('participants'), 0);
|
||||
assert.equal(count('messages'), 0);
|
||||
assert.equal(count('questions'), 0);
|
||||
assert.equal(count('contract_revisions'), 0);
|
||||
|
||||
// everything is gone: token no longer authenticates
|
||||
assert.throws(() => svc.getRoomView(roomId, B), RendezvousError);
|
||||
assert.equal(svc.cleanupExpired(), 0, 'second run is a no-op');
|
||||
});
|
||||
|
||||
test('limits: ttl > 24h rejected, oversize message rejected, 1 participant rejected', () => {
|
||||
const { svc } = makeService();
|
||||
assert.throws(
|
||||
() => svc.createRoom({ title: 'x', ttl_hours: 25, participants: [{ role: 'a' }, { role: 'b' }] }, 'http://x'),
|
||||
RendezvousError,
|
||||
);
|
||||
assert.throws(() => svc.createRoom({ title: 'x', participants: [{ role: 'a' }] }, 'http://x'), RendezvousError);
|
||||
const created = createTwoPartyRoom(svc);
|
||||
assert.throws(
|
||||
() => svc.postMessage(created.room_id, created.participants[0].token, 'a'.repeat(33 * 1024)),
|
||||
RendezvousError,
|
||||
);
|
||||
});
|
||||
|
||||
test('only addressee or author can resolve a question', () => {
|
||||
const { svc } = makeService();
|
||||
const created = createTwoPartyRoom(svc);
|
||||
const [A, B] = created.participants.map((p) => p.token);
|
||||
const q = svc.openQuestion(created.room_id, B, 'check logs', true, created.participants[0].id);
|
||||
// a random third participant would be needed to test "other"; with 2
|
||||
// participants the author/addressee rule reduces to both being allowed.
|
||||
const resolved = svc.resolveQuestion(created.room_id, A, q.id, 'checked: ok');
|
||||
assert.equal(resolved.status, 'resolved');
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["src", "test"]
|
||||
}
|
||||
Reference in New Issue
Block a user