From 1fa7e733ba5739c77c9de16d3d17f22303b86521 Mon Sep 17 00:00:00 2001 From: Alexander Andreev Date: Sun, 6 Sep 2026 20:44:37 +0300 Subject: [PATCH] Any member can destroy a room; observer sees highlighted whose turn it is - DELETE /api/rooms/:id + destroy button in the header of human pages (participant and observer views), with confirmation - turn indicator now names and highlights the expected participant(s) --- docs/API.md | 10 ++++++ packages/core/src/advice.ts | 30 ++++++++++++----- packages/core/src/markdown.ts | 2 ++ packages/core/src/service.ts | 21 +++++++++++- packages/core/src/types.ts | 2 ++ packages/core/test/core.test.ts | 20 +++++++++++ packages/server/src/index.ts | 22 +++++++++++-- packages/server/src/pages.ts | 42 ++++++++++++++++++++---- packages/server/test/integration.test.ts | 33 +++++++++++++++++++ 9 files changed, 164 insertions(+), 18 deletions(-) diff --git a/docs/API.md b/docs/API.md index 8e3b609..8026cbf 100644 --- a/docs/API.md +++ b/docs/API.md @@ -61,6 +61,16 @@ human to watch the negotiation and see whose turn it is). Rate limited per IP (default 10/hour, configurable via `RATE_LIMIT_CREATE_PER_HOUR`). +### Destroy room (any member, any time) + +``` +DELETE /api/rooms/:id # Authorization: Bearer +``` + +Immediately and irreversibly deletes the room with everything: messages, +questions, contract, tokens and links. Available to every participant and to +the observer — no matter the room status. + ### Read room (agent state) ``` diff --git a/packages/core/src/advice.ts b/packages/core/src/advice.ts index a096798..0f91739 100644 --- a/packages/core/src/advice.ts +++ b/packages/core/src/advice.ts @@ -94,33 +94,47 @@ export function computeAdvice(input: AdviceInput): string { /** * Room-level "whose turn is it" for the human observer. * Priority: blocking questions first, then contract agreements, then finalize. + * Returns the human-readable hint plus the roles the room is waiting on + * (empty when nobody in particular is expected to move). */ export function computeTurn( status: string, participants: { id: string; role: string }[], openQuestions: { addressed_to: string | null; participant_id: string; blocking: boolean; question: string }[], contract: { version: number; agreements: { participant_id: string; version: number }[] } | null, -): string { - if (status === 'expired') return 'Room expired — all data has been deleted.'; - if (status === 'agreed') return 'Done: contract agreed by everyone. Fetch final.md before the room expires.'; +): { text: string; waiting_for: string[] } { + if (status === 'expired') return { text: 'Room expired — all data has been deleted.', waiting_for: [] }; + if (status === 'agreed') + return { text: 'Done: contract agreed by everyone. Fetch final.md before the room expires.', waiting_for: [] }; const roleOf = (id: string) => participants.find((p) => p.id === id)?.role ?? 'unknown'; const blocking = openQuestions.filter((q) => q.blocking); if (blocking.length > 0) { const q = blocking[0]; - const who = q.addressed_to ? roleOf(q.addressed_to) : `${participants.filter((p) => p.id !== q.participant_id).map((p) => p.role).join(', ')} (asked by ${roleOf(q.participant_id)})`; - return `Waiting for ${who} to answer/resolve the blocking question: "${q.question.slice(0, 120)}"${q.question.length > 120 ? '…' : ''}`; + const waiting = q.addressed_to + ? [roleOf(q.addressed_to)] + : participants.filter((p) => p.id !== q.participant_id).map((p) => p.role); + return { + text: `Waiting for ${waiting.join(', ')} to answer/resolve the blocking question: "${q.question.slice(0, 120)}"${q.question.length > 120 ? '…' : ''}`, + waiting_for: waiting, + }; } if (!contract || contract.version === 0) { - return 'No blocking questions. Waiting for someone to draft the Agreed Contract.'; + return { text: 'No blocking questions. Waiting for someone to draft the Agreed Contract.', waiting_for: [] }; } const pending = participants.filter( (p) => (contract.agreements.find((a) => a.participant_id === p.id)?.version ?? 0) !== contract.version, ); if (pending.length > 0) { - return `Contract v${contract.version} is on the table. Waiting for ${pending.map((p) => p.role).join(', ')} to agree or propose changes.`; + return { + text: `Contract v${contract.version} is on the table. Waiting for ${pending.map((p) => p.role).join(', ')} to agree or propose changes.`, + waiting_for: pending.map((p) => p.role), + }; } - return 'All questions resolved and everyone agreed — the next agree call finalizes the room.'; + return { + text: 'All questions resolved and everyone agreed — the next agree call finalizes the room.', + waiting_for: [], + }; } export function availableActions(status: string): string[] { diff --git a/packages/core/src/markdown.ts b/packages/core/src/markdown.ts index dc26794..e038682 100644 --- a/packages/core/src/markdown.ts +++ b/packages/core/src/markdown.ts @@ -9,6 +9,7 @@ export function renderObserverMarkdown(o: ObserverView, baseUrl: string): string parts.push(`Status: ${o.room_status} · expires ${o.room.expires_at} (all data deleted then).`); parts.push(''); parts.push(`**Whose turn:** ${o.turn}`); + if (o.turn_waiting_for.length) parts.push(`**Waiting on:** ${o.turn_waiting_for.map((r) => `**${r}**`).join(', ')}`); parts.push(''); parts.push(`Participants: ${o.participants.map((p) => p.role).join(', ')}`); parts.push(''); @@ -125,6 +126,7 @@ export function renderRoomMarkdown(i: MarkdownInput): string { 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'); + parts.push('- `DELETE /api/rooms/' + i.roomId + '` — destroy the room immediately (any member; deletes everything, including this token)'); return parts.join('\n'); } diff --git a/packages/core/src/service.ts b/packages/core/src/service.ts index a45d68b..4a1a638 100644 --- a/packages/core/src/service.ts +++ b/packages/core/src/service.ts @@ -318,6 +318,7 @@ export class RendezvousService { : null; const status = room.status as 'open' | 'agreed' | 'expired'; const viewStatus = new Date(room.expires_at).getTime() < Date.now() ? 'expired' : status; + const turn = computeTurn(viewStatus, participants, openQuestions, contract); return { room: { id: room.id, @@ -334,7 +335,8 @@ export class RendezvousService { resolved_questions: resolvedQuestions, current_contract: contract, room_status: viewStatus, - turn: computeTurn(viewStatus, participants, openQuestions, contract), + turn: turn.text, + turn_waiting_for: turn.waiting_for, }; } @@ -539,6 +541,23 @@ export class RendezvousService { }; } + // ---------- destroy ---------- + + /** + * Immediately and irreversibly delete a room. Allowed for any room member: + * a participant (invite token) or the observer (observer token). + */ + destroyRoom(roomId: string, token: string): void { + const room = this.getRoomRow(roomId); + const isObserver = room.observer_token !== null && room.observer_token === token; + const isParticipant = + !!this.db.prepare('SELECT id FROM participants WHERE room_id = ? AND token = ?').get(roomId, token); + if (!isObserver && !isParticipant) { + throw new RendezvousError('forbidden', 'invalid token for this room'); + } + this.db.prepare('DELETE FROM rooms WHERE id = ?').run(roomId); // cascade wipes everything + } + // ---------- TTL cleanup ---------- /** Deletes everything belonging to expired rooms. Returns number of rooms removed. */ diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index a1a0cfb..5040fb5 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -100,6 +100,8 @@ export interface ObserverView { room_status: 'open' | 'agreed' | 'expired'; /** Whose move it is right now, for a human watching the negotiation. */ turn: string; + /** Roles the room is currently waiting on (for UI highlighting). */ + turn_waiting_for: string[]; } export class RendezvousError extends Error { diff --git a/packages/core/test/core.test.ts b/packages/core/test/core.test.ts index e573624..e1c386a 100644 --- a/packages/core/test/core.test.ts +++ b/packages/core/test/core.test.ts @@ -145,6 +145,26 @@ test('limits: ttl > 24h rejected, oversize message rejected, 1 participant rejec ); }); +test('destroyRoom: allowed for participant and observer, rejected for strangers', () => { + const { svc } = makeService(); + const created = createTwoPartyRoom(svc); + const roomId = created.room_id; + const tokenA = created.participants[0].token; + const observerToken = created.observer_url.split('/').pop()!; + // stranger + assert.throws(() => svc.destroyRoom(roomId, 'forged-token'), RendezvousError); + // observer may read and destroy + const view = svc.getObserverView(roomId, observerToken); + assert.ok(view.turn.length > 0); + svc.destroyRoom(roomId, observerToken); + assert.throws(() => svc.getRoomView(roomId, tokenA), RendezvousError, 'room is gone'); + + // participant may also destroy + const c2 = createTwoPartyRoom(svc); + svc.destroyRoom(c2.room_id, c2.participants[1].token); + assert.throws(() => svc.getRoomView(c2.room_id, c2.participants[0].token), RendezvousError); +}); + test('only addressee or author can resolve a question', () => { const { svc } = makeService(); const created = createTwoPartyRoom(svc); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 3307388..3438f01 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -5,7 +5,7 @@ import { dirname } from 'node:path'; import { RendezvousService, Store, renderFinalMarkdown, renderRoomMarkdown, renderObserverMarkdown, 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, landingPage, llmsTxt, observerHtmlPage } from './pages.js'; +import { createHtmlPage, createMarkdownDoc, createdPage, roomHtmlPage, landingPage, llmsTxt, observerHtmlPage, destroyedPage } from './pages.js'; export interface ServerConfig { port: number; @@ -103,7 +103,7 @@ export function buildRouter(service: RendezvousService, cfg: ServerConfig) { contractVersion: view.current_contract?.version ?? 0, }), 'text/markdown; charset=utf-8'); } else { - sendText(ctx.res, 200, roomHtmlPage(view), 'text/html; charset=utf-8'); + sendText(ctx.res, 200, roomHtmlPage(view, token), 'text/html; charset=utf-8'); } }); @@ -142,10 +142,26 @@ export function buildRouter(service: RendezvousService, cfg: ServerConfig) { if (wantMd) { sendText(ctx.res, 200, renderObserverMarkdown(o, cfg.baseUrl), 'text/markdown; charset=utf-8'); } else { - sendText(ctx.res, 200, observerHtmlPage(o), 'text/html; charset=utf-8'); + sendText(ctx.res, 200, observerHtmlPage(o, token), 'text/html; charset=utf-8'); } }); + // destroy from the human UI: works with an observer or participant token + const uiDestroy = (ctx: Ctx) => { + service.destroyRoom(ctx.params.roomId, ctx.params.token); + sendText(ctx.res, 200, destroyedPage(), 'text/html; charset=utf-8'); + }; + router.on('POST', '/o/:roomId/:token/destroy', uiDestroy); + router.on('POST', '/r/:roomId/:token/destroy', uiDestroy); + + // API destroy: any room member (participant token or observer token) + router.on('DELETE', '/api/rooms/:id', (ctx) => { + const t = getToken(ctx); + if (!t) throw new RendezvousError('forbidden', 'token required (participant or observer)'); + service.destroyRoom(ctx.params.id, t); + sendJson(ctx.res, 200, { destroyed: true, room_id: ctx.params.id }); + }); + function apiRoom(ctx: Ctx) { const token = requireToken(ctx); const view = service.getRoomView(ctx.params.id, token); diff --git a/packages/server/src/pages.ts b/packages/server/src/pages.ts index a7fe6ff..3231aec 100644 --- a/packages/server/src/pages.ts +++ b/packages/server/src/pages.ts @@ -91,9 +91,14 @@ const PAGE_CSS = ` .q.blocking { background: rgba(200,60,60,.14); } .q.nonblocking { background: rgba(60,120,200,.10); } .q.resolved { opacity: .65; } + .q.turn { background: rgba(230,160,30,.16); } + .turn-role { font-weight: 700; text-decoration: underline; } 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; } + .topbar { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; } + .destroy { margin: 0; padding: .25rem .7rem; font-size: .8rem; background: none; border: 1px solid rgba(200,60,60,.6); color: inherit; border-radius: 6px; cursor: pointer; } + .destroy:hover { background: rgba(200,60,60,.14); } .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; } @@ -155,8 +160,32 @@ ${invites} ); } +function destroyForm(roomId: string, token: string, basePath: 'o' | 'r'): string { + return `
+
`; +} + +/** Shown after a member destroyed the room. */ +export function destroyedPage(): string { + return page( + 'Room destroyed', + `

Room destroyed

+

The room and all its data (messages, questions, contract, tokens, observer link) have been deleted. Nothing remains on the server.

+

Create a new rendezvous

`, + ); +} + /** Read-only observer page for the human: same information, plus whose turn it is. */ -export function observerHtmlPage(o: ObserverView): string { +export function observerHtmlPage(o: ObserverView, token: string): string { + const waiting = new Set(o.turn_waiting_for); + const roleList = o.participants + .map((p) => + waiting.has(p.role) + ? `► ${escapeHtml(p.role)}` + : escapeHtml(p.role), + ) + .join(', '); const msgs = o.conversation .map( (m) => @@ -178,10 +207,10 @@ export function observerHtmlPage(o: ObserverView): string { const contract = o.current_contract; return page( o.room.title, - `

${escapeHtml(o.room.title)}

+ `

${escapeHtml(o.room.title)}

${destroyForm(o.room.id, token, 'o')}

Status: ${escapeHtml(o.room.status)} · Goal: ${escapeHtml(o.room.goal || '(not set)')} · Expires: ${escapeHtml(o.room.expires_at)}

-

Participants: ${o.participants.map((p) => escapeHtml(p.role)).join(', ')} — you are watching as observer (read-only).

-
Whose turn: ${escapeHtml(o.turn)}
+

Participants: ${roleList} — you are watching as observer (read-only).

+
⏳ Whose turn: ${escapeHtml(o.turn)}

Open questions

${openQs || '

(none)

'} ${resolvedQs ? `

Resolved questions

${resolvedQs}` : ''}

Agreed Contract (v${contract ? contract.version : 0})

@@ -282,6 +311,7 @@ The observer URL (${baseUrl}/o//, also as .md) shows the n - PUT ${baseUrl}/api/rooms//contract — {"markdown":"## Facts\\n..."} — proposes a NEW version all participants must agree to. - POST ${baseUrl}/api/rooms//agree — agree to the current contract version; when everyone agreed to the same version AND no blocking questions remain, status becomes agreed. - GET ${baseUrl}/api/rooms//final.md — final Markdown artifact (also available before finalization as a draft view). +- DELETE ${baseUrl}/api/rooms/ — destroy the room immediately and irreversibly. Available to ANY member (participant or observer) at any time; deletes messages, questions, contract, tokens and all links. ## Negotiation protocol (expected agent behavior) @@ -306,7 +336,7 @@ The observer URL (${baseUrl}/o//, also as .md) shows the n } /** Read-only human view of the negotiation (same token auth as the API). */ -export function roomHtmlPage(v: RoomView): string { +export function roomHtmlPage(v: RoomView, token: string): string { const msgs = v.conversation .map( (m) => @@ -328,7 +358,7 @@ export function roomHtmlPage(v: RoomView): string { const contract = v.current_contract; return page( v.room.title, - `

${escapeHtml(v.room.title)}

+ `

${escapeHtml(v.room.title)}

${destroyForm(v.room.id, token, 'r')}

Status: ${escapeHtml(v.room.status)} · Goal: ${escapeHtml(v.room.goal || '(not set)')} · Expires: ${escapeHtml(v.room.expires_at)}

Participants: ${v.participants.map((p) => escapeHtml(p.role)).join(', ')} — you are viewing as ${escapeHtml(v.your_role)}.

Open questions

${openQs || '

(none)

'} diff --git a/packages/server/test/integration.test.ts b/packages/server/test/integration.test.ts index b3160a3..20fbf6f 100644 --- a/packages/server/test/integration.test.ts +++ b/packages/server/test/integration.test.ts @@ -116,6 +116,17 @@ test('HTTP: cannot read room by id without token; unknown routes 404; human page // wrong observer token -> forbidden assert.equal((await fetch(`${baseUrl}/o/${created.room_id}/nope`)).status, 403); + // observer HTML highlights whose turn it is after a blocking question appears + const pidA = created.participants[0].id; + const tokB = created.invite_urls[1].split('/').pop(); + await fetch(`${baseUrl}/api/rooms/${created.room_id}/questions`, { + method: 'POST', headers: { authorization: `Bearer ${tokB}`, 'content-type': 'application/json' }, + body: JSON.stringify({ question: 'verify X on your side', blocking: true, addressed_to_participant_id: pidA }), + }); + const obsHtml2 = await (await fetch(created.observer_url)).text(); + assert.match(obsHtml2, /turn-role/); + assert.match(obsHtml2, /windows-1c/); + 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}`); @@ -136,6 +147,28 @@ test('HTTP: cannot read room by id without token; unknown routes 404; human page assert.match(roomHtml, /windows-1c/); const health = await (await fetch(`${baseUrl}/health`)).json(); assert.ok(health.ok); + + // any member can destroy (checked last — it deletes the room) + const del = await fetch(`${baseUrl}/api/rooms/${created.room_id}`, { + method: 'DELETE', headers: { authorization: `Bearer ${obsToken}` }, + }); + assert.equal(del.status, 200); + assert.equal( + (await fetch(`${baseUrl}/api/rooms/${created.room_id}`, { headers: { authorization: `Bearer ${created.participants[0].token}` } })).status, + 404, + ); + // forged token cannot destroy + const c2 = (await (await fetch(`${baseUrl}/api/rooms`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(ROOM_PAYLOAD), + })).json()) as any; + assert.equal( + (await fetch(`${baseUrl}/api/rooms/${c2.room_id}`, { method: 'DELETE', headers: { authorization: 'Bearer nope' } })).status, + 403, + ); + // participant can destroy via the UI form endpoint + const uiDel = await fetch(`${c2.invite_urls[0]}/destroy`, { method: 'POST' }); + assert.equal(uiDel.status, 200); + assert.match(await uiDel.text(), /destroyed/i); } finally { app.close(); }