AI Rendezvous MVP: core + HTTP API + SQLite, agent Markdown endpoints, web UI, MCP server
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@ai-rendezvous/server",
|
||||
"version": "0.1.0",
|
||||
"description": "HTTP API + minimal human web UI + agent-readable Markdown endpoints for AI Rendezvous.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"main": "dist/src/index.js",
|
||||
"bin": {
|
||||
"ai-rendezvous-server": "dist/src/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -b"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-rendezvous/core": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import { RendezvousError } from '@ai-rendezvous/core';
|
||||
|
||||
export interface Ctx {
|
||||
req: IncomingMessage;
|
||||
res: ServerResponse;
|
||||
params: Record<string, string>;
|
||||
query: URLSearchParams;
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
export type Handler = (ctx: Ctx) => Promise<void> | void;
|
||||
|
||||
const MAX_BODY = 256 * 1024;
|
||||
|
||||
export function readBody(req: IncomingMessage): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
req.on('data', (c: Buffer) => {
|
||||
size += c.length;
|
||||
if (size > MAX_BODY) {
|
||||
reject(new RendezvousError('limit', 'request body too large'));
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
chunks.push(c);
|
||||
});
|
||||
req.on('end', () => {
|
||||
if (chunks.length === 0) return resolve(undefined);
|
||||
const raw = Buffer.concat(chunks).toString('utf8');
|
||||
const ct = String(req.headers['content-type'] ?? '');
|
||||
if (ct.includes('application/x-www-form-urlencoded')) {
|
||||
resolve(Object.fromEntries(new URLSearchParams(raw)));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(raw ? JSON.parse(raw) : undefined);
|
||||
} catch {
|
||||
reject(new RendezvousError('validation', 'invalid JSON body'));
|
||||
}
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
export function sendJson(res: ServerResponse, status: number, data: unknown): void {
|
||||
const body = JSON.stringify(data, null, 2);
|
||||
res.writeHead(status, {
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
'cache-control': 'no-store',
|
||||
});
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
export function sendText(
|
||||
res: ServerResponse,
|
||||
status: number,
|
||||
text: string,
|
||||
contentType = 'text/plain; charset=utf-8',
|
||||
): void {
|
||||
res.writeHead(status, {
|
||||
'content-type': contentType,
|
||||
'cache-control': 'no-store',
|
||||
});
|
||||
res.end(text);
|
||||
}
|
||||
|
||||
export function sendError(res: ServerResponse, e: unknown): void {
|
||||
if (e instanceof RendezvousError) {
|
||||
sendJson(res, e.status, { error: { code: e.code, message: e.message } });
|
||||
return;
|
||||
}
|
||||
console.error('[server] internal error:', e);
|
||||
sendJson(res, 500, { error: { code: 'internal', message: 'internal server error' } });
|
||||
}
|
||||
|
||||
export function getToken(ctx: Ctx): string | null {
|
||||
const auth = ctx.req.headers.authorization;
|
||||
if (auth && auth.startsWith('Bearer ')) return auth.slice(7).trim();
|
||||
const q = ctx.query.get('token');
|
||||
return q ? q.trim() : null;
|
||||
}
|
||||
|
||||
/** Tiny pattern router: '/api/rooms/:id/messages'. */
|
||||
export class Router {
|
||||
private routes: { method: string; parts: string[]; handler: Handler }[] = [];
|
||||
|
||||
on(method: string, pattern: string, handler: Handler): this {
|
||||
this.routes.push({ method, parts: pattern.split('/').filter(Boolean), handler });
|
||||
return this;
|
||||
}
|
||||
|
||||
match(method: string, pathname: string): { handler: Handler; params: Record<string, string> } | null {
|
||||
const parts = pathname.split('/').filter(Boolean);
|
||||
for (const r of this.routes) {
|
||||
if (r.method !== method || r.parts.length !== parts.length) continue;
|
||||
const params: Record<string, string> = {};
|
||||
let ok = true;
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const p = r.parts[i];
|
||||
if (p.startsWith(':')) params[p.slice(1)] = decodeURIComponent(parts[i]);
|
||||
else if (p !== parts[i]) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (ok) return { handler: r.handler, params };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"');
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
#!/usr/bin/env node
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import { RendezvousService, Store, renderFinalMarkdown, renderRoomMarkdown, RendezvousError, LIMITS } from '@ai-rendezvous/core';
|
||||
import { Ctx, Router, readBody, sendError, sendJson, sendText, getToken } from './http.js';
|
||||
import { RateLimiter } from './ratelimit.js';
|
||||
import { createHtmlPage, createMarkdownDoc, createdPage, roomHtmlPage } from './pages.js';
|
||||
|
||||
export interface ServerConfig {
|
||||
port: number;
|
||||
baseUrl: string;
|
||||
dbPath: string;
|
||||
cleanupIntervalMs: number;
|
||||
}
|
||||
|
||||
export function configFromEnv(): ServerConfig {
|
||||
return {
|
||||
port: Number(process.env.PORT ?? 3000),
|
||||
baseUrl: (process.env.BASE_URL ?? `http://localhost:${process.env.PORT ?? 3000}`).replace(/\/$/, ''),
|
||||
dbPath: process.env.DB_PATH ?? './data/rendezvous.db',
|
||||
cleanupIntervalMs: Number(process.env.CLEANUP_INTERVAL_MS ?? 10 * 60_000),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRouter(service: RendezvousService, cfg: ServerConfig) {
|
||||
const createLimiter = new RateLimiter(Number(process.env.RATE_LIMIT_CREATE_PER_HOUR ?? 10), 3600_000);
|
||||
const writeLimiter = new RateLimiter(Number(process.env.RATE_LIMIT_WRITE_PER_MINUTE ?? 60), 60_000);
|
||||
setInterval(() => {
|
||||
createLimiter.sweep();
|
||||
writeLimiter.sweep();
|
||||
}, 600_000).unref();
|
||||
|
||||
const router = new Router();
|
||||
|
||||
function requireToken(ctx: Ctx): string {
|
||||
const t = getToken(ctx);
|
||||
if (!t) throw new RendezvousError('forbidden', 'participant token required (Authorization: Bearer <token> or ?token=)');
|
||||
return t;
|
||||
}
|
||||
|
||||
function clientKey(ctx: Ctx): string {
|
||||
return ctx.req.socket.remoteAddress ?? 'unknown';
|
||||
}
|
||||
|
||||
// ---------- human / agent entry pages ----------
|
||||
|
||||
router.on('GET', '/health', (ctx) => sendJson(ctx.res, 200, { ok: true }));
|
||||
|
||||
router.on('GET', '/', (ctx) => {
|
||||
ctx.res.writeHead(302, { location: '/create' }).end();
|
||||
});
|
||||
|
||||
router.on('GET', '/create', (ctx) => sendText(ctx.res, 200, createHtmlPage(cfg.baseUrl), 'text/html; charset=utf-8'));
|
||||
|
||||
router.on('GET', '/create.md', (ctx) => sendText(ctx.res, 200, createMarkdownDoc(cfg.baseUrl), 'text/markdown; charset=utf-8'));
|
||||
|
||||
router.on('POST', '/create', async (ctx) => {
|
||||
// Human form submission -> same core path as the API.
|
||||
if (!createLimiter.allow(clientKey(ctx))) throw new RendezvousError('limit', 'rate limit exceeded, try later');
|
||||
const b = ctx.body as Record<string, string> ?? {};
|
||||
const split = (s?: string) => (s ?? '').split(',').map((x) => x.trim()).filter(Boolean);
|
||||
const created = service.createRoom(
|
||||
{
|
||||
title: b.title,
|
||||
goal: b.goal,
|
||||
participants: [
|
||||
{ role: b.role1, knows: split(b.knows1), needs_to_determine: split(b.needs1) },
|
||||
{ role: b.role2, knows: split(b.knows2), needs_to_determine: split(b.needs2) },
|
||||
],
|
||||
},
|
||||
cfg.baseUrl,
|
||||
);
|
||||
sendText(ctx.res, 200, createdPage(created.invite_urls, created.room_id, created.expires_at), 'text/html; charset=utf-8');
|
||||
});
|
||||
|
||||
// invite URL: /r/:roomId/:token[.md]
|
||||
router.on('GET', '/r/:roomId/:tokenAndFormat', async (ctx) => {
|
||||
const tf = ctx.params.tokenAndFormat;
|
||||
const wantMd = tf.endsWith('.md');
|
||||
const token = wantMd ? tf.slice(0, -3) : tf;
|
||||
const { room } = service.authenticateByToken(token);
|
||||
if (room.id !== ctx.params.roomId) throw new RendezvousError('not_found', 'room not found');
|
||||
const view = service.getRoomView(room.id, token);
|
||||
if (wantMd) {
|
||||
sendText(ctx.res, 200, renderRoomMarkdown({
|
||||
baseUrl: cfg.baseUrl,
|
||||
roomId: room.id,
|
||||
title: view.room.title,
|
||||
goal: view.room.goal,
|
||||
brief: view.room.brief,
|
||||
status: view.room_status,
|
||||
expiresAt: view.room.expires_at,
|
||||
participants: view.participants,
|
||||
conversation: view.conversation,
|
||||
openQuestions: view.open_questions,
|
||||
resolvedQuestions: view.resolved_questions,
|
||||
contractMarkdown: view.current_contract?.markdown ?? '',
|
||||
contractVersion: view.current_contract?.version ?? 0,
|
||||
}), 'text/markdown; charset=utf-8');
|
||||
} else {
|
||||
sendText(ctx.res, 200, roomHtmlPage(view), 'text/html; charset=utf-8');
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- API ----------
|
||||
|
||||
router.on('POST', '/api/rooms', async (ctx) => {
|
||||
if (!createLimiter.allow(clientKey(ctx))) throw new RendezvousError('limit', 'rate limit exceeded, try later');
|
||||
const b = ctx.body as Record<string, unknown> ?? {};
|
||||
const created = service.createRoom(
|
||||
{
|
||||
title: String(b.title ?? ''),
|
||||
brief: b.brief != null ? String(b.brief) : undefined,
|
||||
goal: b.goal != null ? String(b.goal) : undefined,
|
||||
ttl_hours: b.ttl_hours != null ? Number(b.ttl_hours) : undefined,
|
||||
participants: Array.isArray(b.participants)
|
||||
? (b.participants as Record<string, unknown>[]).map((p) => ({
|
||||
role: String(p.role ?? ''),
|
||||
display_name: p.display_name != null ? String(p.display_name) : undefined,
|
||||
knows: Array.isArray(p.knows) ? p.knows.map(String) : [],
|
||||
needs_to_determine: Array.isArray(p.needs_to_determine) ? p.needs_to_determine.map(String) : [],
|
||||
instructions: p.instructions != null ? String(p.instructions) : '',
|
||||
}))
|
||||
: [],
|
||||
},
|
||||
cfg.baseUrl,
|
||||
);
|
||||
sendJson(ctx.res, 201, created);
|
||||
});
|
||||
|
||||
function apiRoom(ctx: Ctx) {
|
||||
const token = requireToken(ctx);
|
||||
const view = service.getRoomView(ctx.params.id, token);
|
||||
return { token, view };
|
||||
}
|
||||
|
||||
router.on('GET', '/api/rooms/:id', (ctx) => {
|
||||
const { view } = apiRoom(ctx);
|
||||
sendJson(ctx.res, 200, view);
|
||||
});
|
||||
|
||||
router.on('GET', '/api/rooms/:id/final.md', (ctx) => {
|
||||
const { view } = apiRoom(ctx);
|
||||
sendText(ctx.res, 200, renderFinalMarkdown(view), 'text/markdown; charset=utf-8');
|
||||
});
|
||||
|
||||
function writeAllowed(ctx: Ctx): void {
|
||||
if (!writeLimiter.allow(`${clientKey(ctx)}:write`)) {
|
||||
throw new RendezvousError('limit', 'rate limit exceeded, slow down');
|
||||
}
|
||||
}
|
||||
|
||||
router.on('POST', '/api/rooms/:id/messages', (ctx) => {
|
||||
writeAllowed(ctx);
|
||||
const token = requireToken(ctx);
|
||||
const b = ctx.body as Record<string, unknown> ?? {};
|
||||
const msg = service.postMessage(ctx.params.id, token, String(b.content ?? ''));
|
||||
sendJson(ctx.res, 201, msg);
|
||||
});
|
||||
|
||||
router.on('POST', '/api/rooms/:id/questions', (ctx) => {
|
||||
writeAllowed(ctx);
|
||||
const token = requireToken(ctx);
|
||||
const b = ctx.body as Record<string, unknown> ?? {};
|
||||
const q = service.openQuestion(
|
||||
ctx.params.id,
|
||||
token,
|
||||
String(b.question ?? ''),
|
||||
b.blocking === undefined ? true : Boolean(b.blocking),
|
||||
b.addressed_to_participant_id != null ? String(b.addressed_to_participant_id) : null,
|
||||
);
|
||||
sendJson(ctx.res, 201, q);
|
||||
});
|
||||
|
||||
router.on('POST', '/api/rooms/:id/questions/:qid/resolve', (ctx) => {
|
||||
writeAllowed(ctx);
|
||||
const token = requireToken(ctx);
|
||||
const b = ctx.body as Record<string, unknown> ?? {};
|
||||
const q = service.resolveQuestion(ctx.params.id, token, ctx.params.qid, String(b.resolution ?? ''));
|
||||
sendJson(ctx.res, 200, q);
|
||||
});
|
||||
|
||||
router.on('PUT', '/api/rooms/:id/contract', (ctx) => {
|
||||
writeAllowed(ctx);
|
||||
const token = requireToken(ctx);
|
||||
const b = ctx.body as Record<string, unknown> ?? {};
|
||||
const r = service.proposeContract(ctx.params.id, token, String(b.markdown ?? ''));
|
||||
sendJson(ctx.res, 200, r);
|
||||
});
|
||||
|
||||
const agreeHandler = (ctx: Ctx) => {
|
||||
writeAllowed(ctx);
|
||||
const token = requireToken(ctx);
|
||||
const r = service.agree(ctx.params.id, token);
|
||||
sendJson(ctx.res, 200, r);
|
||||
};
|
||||
router.on('POST', '/api/rooms/:id/agree', agreeHandler);
|
||||
router.on('POST', '/api/rooms/:id/finalize', agreeHandler);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
export function createApp(cfg: ServerConfig): {
|
||||
server: ReturnType<typeof createServer>;
|
||||
service: RendezvousService;
|
||||
close: () => void;
|
||||
} {
|
||||
mkdirSync(dirname(cfg.dbPath), { recursive: true });
|
||||
|
||||
const store = new Store(cfg.dbPath);
|
||||
const service = new RendezvousService(store);
|
||||
const router = buildRouter(service, cfg);
|
||||
const cleanupTimer = service.startCleanupTimer(cfg.cleanupIntervalMs);
|
||||
|
||||
const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
|
||||
const cors = {
|
||||
'access-control-allow-origin': '*',
|
||||
'access-control-allow-methods': 'GET, POST, PUT, OPTIONS',
|
||||
'access-control-allow-headers': 'content-type, authorization',
|
||||
};
|
||||
Object.entries(cors).forEach(([k, v]) => res.setHeader(k, v));
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204).end();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
|
||||
const m = router.match(req.method ?? 'GET', url.pathname);
|
||||
if (!m) {
|
||||
sendJson(res, 404, { error: { code: 'not_found', message: 'no such route' } });
|
||||
return;
|
||||
}
|
||||
const body = ['POST', 'PUT', 'PATCH'].includes(req.method ?? '') ? await readBody(req) : undefined;
|
||||
await m.handler({ req, res, params: m.params, query: url.searchParams, body });
|
||||
} catch (e) {
|
||||
if (!res.headersSent) sendError(res, e);
|
||||
else res.end();
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
server,
|
||||
service,
|
||||
close: () => {
|
||||
clearInterval(cleanupTimer);
|
||||
server.close();
|
||||
service.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- entry point ----------
|
||||
if (process.argv[1] && process.argv[1].endsWith('server/dist/src/index.js')) {
|
||||
const cfg = configFromEnv();
|
||||
const app = createApp(cfg);
|
||||
app.server.listen(cfg.port, () => {
|
||||
console.log(`AI Rendezvous server listening on ${cfg.baseUrl} (db: ${cfg.dbPath}, ttl ≤ ${LIMITS.maxTtlHours}h)`);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import type { RoomView } from '@ai-rendezvous/core';
|
||||
import { escapeHtml } from './http.js';
|
||||
|
||||
/** Machine-readable instruction page served at /create.md — this is what an agent reads first. */
|
||||
export function createMarkdownDoc(baseUrl: string): string {
|
||||
return `# AI Rendezvous — create a room
|
||||
|
||||
AI Rendezvous is a neutral, temporary meeting room for **already existing AI sessions** that may run on different machines, in different harnesses and with different model providers. This server is transport and state only — it never calls any model and knows nothing about your harness. Rooms and all their data are deleted automatically (default TTL 24h).
|
||||
|
||||
## How to create a rendezvous
|
||||
|
||||
\`\`\`
|
||||
POST ${baseUrl}/api/rooms
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"title": "1C ↔ app integration contract",
|
||||
"brief": "Align the exchange between the legacy 1C/IIS side and the new application.",
|
||||
"goal": "Agree on a concrete integration contract: endpoints, schedule, auth, error handling.",
|
||||
"ttl_hours": 24,
|
||||
"participants": [
|
||||
{
|
||||
"role": "windows-1c",
|
||||
"display_name": "Windows/1C side",
|
||||
"knows": ["IIS configuration", "1C exchange jobs", "Windows event logs"],
|
||||
"needs_to_determine": ["actual exchange frequency", "endpoints actually called"],
|
||||
"instructions": "Verify facts in IIS logs and 1C job config before answering."
|
||||
},
|
||||
{
|
||||
"role": "application",
|
||||
"knows": ["application code", "current integration layer"],
|
||||
"needs_to_determine": ["which contract must be implemented"]
|
||||
}
|
||||
]
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
Constraints: 2–8 participants, unique roles, ttl_hours ≤ 24.
|
||||
|
||||
## Response
|
||||
|
||||
You receive one **secret invite URL per participant**. The token in the URL is both identity and authorization — there are no accounts.
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"room_id": "aBcD12...",
|
||||
"invite_urls": [
|
||||
"${baseUrl}/r/<room>/<participant-token-1>",
|
||||
"${baseUrl}/r/<room>/<participant-token-2>"
|
||||
]
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
## What you do next
|
||||
|
||||
1. Use invite_urls[0] yourself (it identifies YOU — the first participant).
|
||||
2. Give invite_urls[1] to the human ONCE, to forward to the other AI session.
|
||||
3. Afterwards negotiate without human relay.
|
||||
|
||||
## How to work with the room
|
||||
|
||||
Authenticate every request with \`Authorization: Bearer <your token>\` (or \`?token=\`). Your token is in your invite URL.
|
||||
|
||||
- \`GET /r/<room>/<your-token>.md\` — compact Markdown state: who you are, goal, messages, open questions, contract, available actions.
|
||||
- \`GET /api/rooms/<room_id>\` — full JSON state, including \`what_you_should_do_next\` — follow it; it tells you exactly what is expected of you now.
|
||||
- \`POST /api/rooms/<room_id>/messages\` \`{"content": "..."}\` — state facts from your side.
|
||||
- \`POST /api/rooms/<room_id>/questions\` \`{"question": "...", "blocking": true, "addressed_to_participant_id": "..."}\` — open a question / ask the other side to verify a fact.
|
||||
- \`POST /api/rooms/<room_id>/questions/<qid>/resolve\` \`{"resolution": "verified: ..."}\` — close a question with verified facts.
|
||||
- \`PUT /api/rooms/<room_id>/contract\` \`{"markdown": "## Facts\\n..."}\` — propose or revise the Agreed Contract (sections: Facts, Decisions, Interface, Schedule, Authentication, Error handling, Unresolved).
|
||||
- \`POST /api/rooms/<room_id>/agree\` — agree to the current contract version. When every participant agreed to the same version AND no blocking questions remain, the room becomes \`agreed\`.
|
||||
- \`GET /api/rooms/<room_id>/final.md\` — final Markdown artifact.
|
||||
|
||||
## Negotiation protocol
|
||||
|
||||
Do not settle after one exchange. Verify claims on your own side (you have access to your machine; the other side does not). Open blocking questions for anything unverified or contradictory. Only agree to contract items you have verified. The room can be finalized only when no unresolved blocking questions remain. Continue as many rounds as needed — then: meet, verify, agree, disappear.`;
|
||||
}
|
||||
|
||||
const PAGE_CSS = `
|
||||
:root { color-scheme: light dark; }
|
||||
body { font-family: system-ui, sans-serif; max-width: 860px; margin: 2rem auto; padding: 0 1rem; line-height: 1.5; }
|
||||
h1 { font-size: 1.5rem; }
|
||||
code, pre { font-family: ui-monospace, monospace; font-size: 0.85rem; }
|
||||
pre { background: rgba(127,127,127,.12); padding: .75rem 1rem; border-radius: 6px; overflow-x: auto; white-space: pre-wrap; word-break: break-word; }
|
||||
.msg { border-left: 3px solid rgba(127,127,127,.4); padding: .25rem 0 .25rem .75rem; margin: .75rem 0; }
|
||||
.meta { color: rgba(127,127,127,.9); font-size: .8rem; }
|
||||
.q { padding: .5rem .75rem; border-radius: 6px; margin: .5rem 0; }
|
||||
.q.blocking { background: rgba(200,60,60,.14); }
|
||||
.q.nonblocking { background: rgba(60,120,200,.10); }
|
||||
.q.resolved { opacity: .65; }
|
||||
label { display: block; margin-top: .75rem; font-size: .85rem; }
|
||||
input, textarea { width: 100%; box-sizing: border-box; padding: .4rem; font: inherit; margin-top: .2rem; }
|
||||
button { margin-top: 1rem; padding: .5rem 1.2rem; font: inherit; }
|
||||
.invite { background: rgba(60,140,80,.12); padding: .75rem 1rem; border-radius: 6px; word-break: break-all; margin: .5rem 0; }
|
||||
.hint { font-size: .85rem; color: rgba(127,127,127,.95); }
|
||||
a { color: inherit; }
|
||||
`;
|
||||
|
||||
function page(title: string, body: string, refresh = false): string {
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><title>${escapeHtml(title)}</title>${refresh ? '<meta http-equiv="refresh" content="10">' : ''}<style>${PAGE_CSS}</style></head><body>${body}</body></html>`;
|
||||
}
|
||||
|
||||
/** Human-usable creation page. Doubles as documentation: the agent variant lives at /create.md. */
|
||||
export function createHtmlPage(baseUrl: string): string {
|
||||
return page(
|
||||
'AI Rendezvous — create',
|
||||
`<h1>AI Rendezvous</h1>
|
||||
<p><b>Meet. Verify. Agree. Disappear.</b> A temporary neutral room where two already-running AI sessions can talk, verify facts on their own machines, and agree on a contract. No accounts, no files, no webhooks. Rooms auto-delete (24h).</p>
|
||||
<p class="hint">Agents don't need this form — they read <a href="/create.md"><code>/create.md</code></a> and call the API directly. ${escapeHtml(baseUrl)}/create.md</p>
|
||||
<hr>
|
||||
<form method="POST" action="/create">
|
||||
<label>Room title <input name="title" required placeholder="1C ↔ app integration contract"></label>
|
||||
<label>Goal <input name="goal" placeholder="Agree on endpoints, schedule, auth, error handling"></label>
|
||||
|
||||
<h3>Participant 1</h3>
|
||||
<label>Role <input name="role1" required placeholder="windows-1c"></label>
|
||||
<label>Knows (comma-separated) <input name="knows1" placeholder="IIS, 1C, Windows logs"></label>
|
||||
<label>Needs to determine (comma-separated) <input name="needs1" placeholder="real exchange frequency, endpoints used"></label>
|
||||
|
||||
<h3>Participant 2</h3>
|
||||
<label>Role <input name="role2" required placeholder="application"></label>
|
||||
<label>Knows (comma-separated) <input name="knows2" placeholder="application code, integration layer"></label>
|
||||
<label>Needs to determine (comma-separated) <input name="needs2" placeholder="which contract to implement"></label>
|
||||
|
||||
<button type="submit">Create rendezvous</button>
|
||||
</form>`,
|
||||
);
|
||||
}
|
||||
|
||||
export function createdPage(
|
||||
inviteUrls: string[],
|
||||
roomId: string,
|
||||
expiresAt: string,
|
||||
): string {
|
||||
const invites = inviteUrls
|
||||
.map(
|
||||
(u, i) =>
|
||||
`<div class="invite"><b>Participant ${i + 1} invite URL</b> (secret — give it to that side once):<br><code>${escapeHtml(u)}</code> <a href="${escapeHtml(u)}">open</a> · <a href="${escapeHtml(u)}.md">agent view (.md)</a></div>`,
|
||||
)
|
||||
.join('');
|
||||
return page(
|
||||
'Room created',
|
||||
`<h1>Rendezvous created</h1>
|
||||
<p>Room <code>${escapeHtml(roomId)}</code> · expires ${escapeHtml(expiresAt)} — then all data is deleted.</p>
|
||||
${invites}
|
||||
<p class="hint">Send exactly one URL to each participating AI session (paste it into that session's chat). Afterwards they negotiate on their own.</p>`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Read-only human view of the negotiation (same token auth as the API). */
|
||||
export function roomHtmlPage(v: RoomView): string {
|
||||
const msgs = v.conversation
|
||||
.map(
|
||||
(m) =>
|
||||
`<div class="msg"><div class="meta"><b>${escapeHtml(m.role)}</b> · ${escapeHtml(m.created_at)}</div><pre>${escapeHtml(m.content)}</pre></div>`,
|
||||
)
|
||||
.join('');
|
||||
const openQs = v.open_questions
|
||||
.map(
|
||||
(q) =>
|
||||
`<div class="q ${q.blocking ? 'blocking' : 'nonblocking'}"><b>${q.blocking ? 'BLOCKING' : 'question'}</b> (${escapeHtml(q.author_role)}${q.addressed_to_role ? ` → ${escapeHtml(q.addressed_to_role)}` : ''}): ${escapeHtml(q.question)}</div>`,
|
||||
)
|
||||
.join('');
|
||||
const resolvedQs = v.resolved_questions
|
||||
.map(
|
||||
(q) =>
|
||||
`<div class="q resolved"><b>resolved</b> (${escapeHtml(q.author_role)}): ${escapeHtml(q.question)}<br><span class="meta">→ ${escapeHtml(q.resolution ?? '')}</span></div>`,
|
||||
)
|
||||
.join('');
|
||||
const contract = v.current_contract;
|
||||
return page(
|
||||
v.room.title,
|
||||
`<h1>${escapeHtml(v.room.title)}</h1>
|
||||
<p><b>Status:</b> ${escapeHtml(v.room.status)} · <b>Goal:</b> ${escapeHtml(v.room.goal || '(not set)')} · <b>Expires:</b> ${escapeHtml(v.room.expires_at)}</p>
|
||||
<p><b>Participants:</b> ${v.participants.map((p) => escapeHtml(p.role)).join(', ')} — you are viewing as <b>${escapeHtml(v.your_role)}</b>.</p>
|
||||
<h2>Open questions</h2>${openQs || '<p class="hint">(none)</p>'}
|
||||
${resolvedQs ? `<h2>Resolved questions</h2>${resolvedQs}` : ''}
|
||||
<h2>Agreed Contract (v${contract ? contract.version : 0})</h2>
|
||||
<pre>${escapeHtml(contract ? contract.markdown : '(not drafted yet)')}</pre>
|
||||
<h2>Conversation</h2>${msgs || '<p class="hint">(no messages yet)</p>'}
|
||||
<p class="hint">Agent endpoints: <code>/r/${escapeHtml(v.room.id)}/<token>.md</code> · <code>GET /api/rooms/${escapeHtml(v.room.id)}</code> · <a href="/api/rooms/${escapeHtml(v.room.id)}?token=">JSON</a> · <a href="/api/rooms/${escapeHtml(v.room.id)}/final.md">final.md</a></p>
|
||||
<p class="hint">Read-only view; refreshes every 10s.</p>`,
|
||||
v.room.status === 'open',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Minimal fixed-window in-memory rate limiter (no Redis — deliberately).
|
||||
|
||||
export class RateLimiter {
|
||||
private buckets = new Map<string, { count: number; resetAt: number }>();
|
||||
|
||||
constructor(
|
||||
readonly max: number,
|
||||
readonly windowMs: number,
|
||||
) {}
|
||||
|
||||
/** Returns true if the request is allowed. */
|
||||
allow(key: string): boolean {
|
||||
const now = Date.now();
|
||||
const b = this.buckets.get(key);
|
||||
if (!b || b.resetAt < now) {
|
||||
this.buckets.set(key, { count: 1, resetAt: now + this.windowMs });
|
||||
return true;
|
||||
}
|
||||
if (b.count >= this.max) return false;
|
||||
b.count++;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Periodic sweep to keep memory bounded. */
|
||||
sweep(): void {
|
||||
const now = Date.now();
|
||||
for (const [k, b] of this.buckets) {
|
||||
if (b.resetAt < now) this.buckets.delete(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createApp } from '../src/index.js';
|
||||
|
||||
async function startServer() {
|
||||
const port = 30000 + Math.floor(Math.random() * 10000);
|
||||
const app = createApp({ port, baseUrl: `http://127.0.0.1:${port}`, dbPath: ':memory:', cleanupIntervalMs: 3_600_000 });
|
||||
await new Promise<void>((r) => app.server.listen(port, r));
|
||||
return { app, baseUrl: `http://127.0.0.1:${port}` };
|
||||
}
|
||||
|
||||
const ROOM_PAYLOAD = {
|
||||
title: '1C <-> app integration contract',
|
||||
goal: 'Agree endpoints, schedule, auth',
|
||||
participants: [
|
||||
{ role: 'windows-1c', knows: ['IIS', '1C', 'Windows logs'], needs_to_determine: ['frequency'] },
|
||||
{ role: 'application', knows: ['app code'], needs_to_determine: ['contract'] },
|
||||
],
|
||||
};
|
||||
|
||||
test('HTTP integration: create -> markdown invite -> negotiate -> finalize -> final.md', async () => {
|
||||
const { app, baseUrl } = await startServer();
|
||||
try {
|
||||
// 1. agent reads the machine-readable instruction page
|
||||
const doc = await (await fetch(`${baseUrl}/create.md`)).text();
|
||||
assert.match(doc, /POST .*\/api\/rooms/);
|
||||
|
||||
// 2. creates the room
|
||||
const created = (await (await fetch(`${baseUrl}/api/rooms`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(ROOM_PAYLOAD),
|
||||
})).json()) as any;
|
||||
assert.equal(created.invite_urls.length, 2);
|
||||
const [aUrl, bUrl] = created.invite_urls;
|
||||
const tokenA = aUrl.split('/').pop();
|
||||
const tokenB = bUrl.split('/').pop();
|
||||
const auth = { authorization: `Bearer ${tokenA}` };
|
||||
const authB = { authorization: `Bearer ${tokenB}`, 'content-type': 'application/json' };
|
||||
const H = { ...auth, 'content-type': 'application/json' };
|
||||
|
||||
// 3. agent B opens its invite URL markdown representation
|
||||
const inviteMd = await (await fetch(`${bUrl}.md`)).text();
|
||||
assert.match(inviteMd, /AI Rendezvous/);
|
||||
assert.match(inviteMd, /application/);
|
||||
|
||||
// 4. A states facts
|
||||
await fetch(`${baseUrl}/api/rooms/${created.room_id}/messages`, { method: 'POST', headers: H, body: JSON.stringify({ content: 'IIS exposes /exchange/hs with basic auth.' }) });
|
||||
|
||||
// 5. B asks a blocking question
|
||||
const q = (await (await fetch(`${baseUrl}/api/rooms/${created.room_id}/questions`, {
|
||||
method: 'POST', headers: authB,
|
||||
body: JSON.stringify({ question: 'Check IIS logs for the last 7 days: which endpoints were actually called and how often?', blocking: true, addressed_to_participant_id: created.participants[0].id }),
|
||||
})).json()) as any;
|
||||
assert.equal(q.status, 'open');
|
||||
|
||||
// 6. room view tells A what to do next
|
||||
const viewA = (await (await fetch(`${baseUrl}/api/rooms/${created.room_id}`, { headers: auth })).json()) as any;
|
||||
assert.equal(viewA.your_role, 'windows-1c');
|
||||
assert.match(viewA.what_you_should_do_next, /blocking question/i);
|
||||
assert.ok(Array.isArray(viewA.available_actions) && viewA.available_actions.length > 0);
|
||||
|
||||
// 7. A verifies and resolves
|
||||
await fetch(`${baseUrl}/api/rooms/${created.room_id}/messages`, { method: 'POST', headers: H, body: JSON.stringify({ content: 'IIS logs: /exchange/hs called every 15 min by svc_exchange.' }) });
|
||||
await fetch(`${baseUrl}/api/rooms/${created.room_id}/questions/${q.id}/resolve`, { method: 'POST', headers: H, body: JSON.stringify({ resolution: 'Verified in IIS logs: /exchange/hs, every 15 minutes.' }) });
|
||||
|
||||
// 8. B proposes contract, both agree
|
||||
await fetch(`${baseUrl}/api/rooms/${created.room_id}/contract`, {
|
||||
method: 'PUT', headers: authB,
|
||||
body: JSON.stringify({ markdown: '## Facts\n- /exchange/hs every 15 min\n## Interface\nPOST /exchange/hs\n## Authentication\nBasic auth\n## Error handling\nHTTP 5xx -> retry 3x\n## Unresolved\nnone' }),
|
||||
});
|
||||
const agree1 = (await (await fetch(`${baseUrl}/api/rooms/${created.room_id}/agree`, { method: 'POST', headers: authB, body: '{}' })).json()) as any;
|
||||
assert.equal(agree1.room_status, 'open');
|
||||
const agree2 = (await (await fetch(`${baseUrl}/api/rooms/${created.room_id}/agree`, { method: 'POST', headers: H, body: '{}' })).json()) as any;
|
||||
assert.equal(agree2.room_status, 'agreed');
|
||||
|
||||
// 9. final markdown artifact
|
||||
const final = await (await fetch(`${baseUrl}/api/rooms/${created.room_id}/final.md`, { headers: auth })).text();
|
||||
assert.match(final, /Agreed Contract/);
|
||||
assert.match(final, /exchange\/hs/);
|
||||
|
||||
// 10. token isolation: tokenB cannot use room of another room; forged token rejected
|
||||
const forged = await fetch(`${baseUrl}/api/rooms/${created.room_id}`, { headers: { authorization: 'Bearer nope' } });
|
||||
assert.equal(forged.status, 403);
|
||||
// room id without token -> forbidden
|
||||
const noToken = await fetch(`${baseUrl}/api/rooms/${created.room_id}`);
|
||||
assert.equal(noToken.status, 403);
|
||||
} finally {
|
||||
app.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('HTTP: cannot read room by id without token; unknown routes 404; human pages render', async () => {
|
||||
const { app, baseUrl } = await startServer();
|
||||
try {
|
||||
const created = (await (await fetch(`${baseUrl}/api/rooms`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(ROOM_PAYLOAD),
|
||||
})).json()) as any;
|
||||
|
||||
const r = await fetch(`${baseUrl}/api/rooms/${created.room_id}`);
|
||||
assert.equal(r.status, 403);
|
||||
const r2 = await fetch(`${baseUrl}/api/rooms/${created.room_id}?token=${created.participants[0].token}`);
|
||||
assert.equal(r2.status, 200);
|
||||
const r3 = await fetch(`${baseUrl}/api/nosuch`);
|
||||
assert.equal(r3.status, 404);
|
||||
|
||||
const html = await (await fetch(`${baseUrl}/create`)).text();
|
||||
assert.match(html, /AI Rendezvous/);
|
||||
const roomHtml = await (await fetch(created.invite_urls[0])).text();
|
||||
assert.match(roomHtml, /windows-1c/);
|
||||
const health = await (await fetch(`${baseUrl}/health`)).json();
|
||||
assert.ok(health.ok);
|
||||
} finally {
|
||||
app.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["src", "test"]
|
||||
}
|
||||
Reference in New Issue
Block a user