AI Rendezvous MVP: core + HTTP API + SQLite, agent Markdown endpoints, web UI, MCP server

This commit is contained in:
2026-09-06 19:00:16 +03:00
commit f3abd3c741
35 changed files with 4213 additions and 0 deletions
+20
View File
@@ -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"
}
}
+120
View File
@@ -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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
}
+260
View File
@@ -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)`);
});
}
+184
View File
@@ -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: 28 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> &nbsp;<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)}/&lt;token&gt;.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',
);
}
+31
View File
@@ -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);
}
}
}
+116
View File
@@ -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();
}
});
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "."
},
"include": ["src", "test"]
}