Any member can destroy a room; observer sees highlighted whose turn it is
deploy / deploy (push) Canceled after 0s

- 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)
This commit is contained in:
2026-09-06 20:44:37 +03:00
parent 91fc74efc1
commit 1fa7e733ba
9 changed files with 164 additions and 18 deletions
+10
View File
@@ -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`). 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 <participant OR observer token>
```
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) ### Read room (agent state)
``` ```
+22 -8
View File
@@ -94,33 +94,47 @@ export function computeAdvice(input: AdviceInput): string {
/** /**
* Room-level "whose turn is it" for the human observer. * Room-level "whose turn is it" for the human observer.
* Priority: blocking questions first, then contract agreements, then finalize. * 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( export function computeTurn(
status: string, status: string,
participants: { id: string; role: string }[], participants: { id: string; role: string }[],
openQuestions: { addressed_to: string | null; participant_id: string; blocking: boolean; question: string }[], openQuestions: { addressed_to: string | null; participant_id: string; blocking: boolean; question: string }[],
contract: { version: number; agreements: { participant_id: string; version: number }[] } | null, contract: { version: number; agreements: { participant_id: string; version: number }[] } | null,
): string { ): { text: string; waiting_for: string[] } {
if (status === 'expired') return 'Room expired — all data has been deleted.'; if (status === 'expired') return { text: 'Room expired — all data has been deleted.', waiting_for: [] };
if (status === 'agreed') return 'Done: contract agreed by everyone. Fetch final.md before the room expires.'; 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 roleOf = (id: string) => participants.find((p) => p.id === id)?.role ?? 'unknown';
const blocking = openQuestions.filter((q) => q.blocking); const blocking = openQuestions.filter((q) => q.blocking);
if (blocking.length > 0) { if (blocking.length > 0) {
const q = blocking[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)})`; const waiting = q.addressed_to
return `Waiting for ${who} to answer/resolve the blocking question: "${q.question.slice(0, 120)}"${q.question.length > 120 ? '…' : ''}`; ? [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) { 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( const pending = participants.filter(
(p) => (contract.agreements.find((a) => a.participant_id === p.id)?.version ?? 0) !== contract.version, (p) => (contract.agreements.find((a) => a.participant_id === p.id)?.version ?? 0) !== contract.version,
); );
if (pending.length > 0) { 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[] { export function availableActions(status: string): string[] {
+2
View File
@@ -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(`Status: ${o.room_status} · expires ${o.room.expires_at} (all data deleted then).`);
parts.push(''); parts.push('');
parts.push(`**Whose turn:** ${o.turn}`); 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('');
parts.push(`Participants: ${o.participants.map((p) => p.role).join(', ')}`); parts.push(`Participants: ${o.participants.map((p) => p.role).join(', ')}`);
parts.push(''); 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('- `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('- `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('- `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'); return parts.join('\n');
} }
+20 -1
View File
@@ -318,6 +318,7 @@ export class RendezvousService {
: null; : null;
const status = room.status as 'open' | 'agreed' | 'expired'; const status = room.status as 'open' | 'agreed' | 'expired';
const viewStatus = new Date(room.expires_at).getTime() < Date.now() ? 'expired' : status; const viewStatus = new Date(room.expires_at).getTime() < Date.now() ? 'expired' : status;
const turn = computeTurn(viewStatus, participants, openQuestions, contract);
return { return {
room: { room: {
id: room.id, id: room.id,
@@ -334,7 +335,8 @@ export class RendezvousService {
resolved_questions: resolvedQuestions, resolved_questions: resolvedQuestions,
current_contract: contract, current_contract: contract,
room_status: viewStatus, 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 ---------- // ---------- TTL cleanup ----------
/** Deletes everything belonging to expired rooms. Returns number of rooms removed. */ /** Deletes everything belonging to expired rooms. Returns number of rooms removed. */
+2
View File
@@ -100,6 +100,8 @@ export interface ObserverView {
room_status: 'open' | 'agreed' | 'expired'; room_status: 'open' | 'agreed' | 'expired';
/** Whose move it is right now, for a human watching the negotiation. */ /** Whose move it is right now, for a human watching the negotiation. */
turn: string; turn: string;
/** Roles the room is currently waiting on (for UI highlighting). */
turn_waiting_for: string[];
} }
export class RendezvousError extends Error { export class RendezvousError extends Error {
+20
View File
@@ -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', () => { test('only addressee or author can resolve a question', () => {
const { svc } = makeService(); const { svc } = makeService();
const created = createTwoPartyRoom(svc); const created = createTwoPartyRoom(svc);
+19 -3
View File
@@ -5,7 +5,7 @@ import { dirname } from 'node:path';
import { RendezvousService, Store, renderFinalMarkdown, renderRoomMarkdown, renderObserverMarkdown, RendezvousError, LIMITS } from '@ai-rendezvous/core'; import { RendezvousService, Store, renderFinalMarkdown, renderRoomMarkdown, renderObserverMarkdown, RendezvousError, LIMITS } from '@ai-rendezvous/core';
import { Ctx, Router, readBody, sendError, sendJson, sendText, getToken } from './http.js'; import { Ctx, Router, readBody, sendError, sendJson, sendText, getToken } from './http.js';
import { RateLimiter } from './ratelimit.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 { export interface ServerConfig {
port: number; port: number;
@@ -103,7 +103,7 @@ export function buildRouter(service: RendezvousService, cfg: ServerConfig) {
contractVersion: view.current_contract?.version ?? 0, contractVersion: view.current_contract?.version ?? 0,
}), 'text/markdown; charset=utf-8'); }), 'text/markdown; charset=utf-8');
} else { } 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) { if (wantMd) {
sendText(ctx.res, 200, renderObserverMarkdown(o, cfg.baseUrl), 'text/markdown; charset=utf-8'); sendText(ctx.res, 200, renderObserverMarkdown(o, cfg.baseUrl), 'text/markdown; charset=utf-8');
} else { } 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) { function apiRoom(ctx: Ctx) {
const token = requireToken(ctx); const token = requireToken(ctx);
const view = service.getRoomView(ctx.params.id, token); const view = service.getRoomView(ctx.params.id, token);
+36 -6
View File
@@ -91,9 +91,14 @@ const PAGE_CSS = `
.q.blocking { background: rgba(200,60,60,.14); } .q.blocking { background: rgba(200,60,60,.14); }
.q.nonblocking { background: rgba(60,120,200,.10); } .q.nonblocking { background: rgba(60,120,200,.10); }
.q.resolved { opacity: .65; } .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; } label { display: block; margin-top: .75rem; font-size: .85rem; }
input, textarea { width: 100%; box-sizing: border-box; padding: .4rem; font: inherit; margin-top: .2rem; } 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; } 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; } .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); } .hint { font-size: .85rem; color: rgba(127,127,127,.95); }
a { color: inherit; } a { color: inherit; }
@@ -155,8 +160,32 @@ ${invites}
); );
} }
function destroyForm(roomId: string, token: string, basePath: 'o' | 'r'): string {
return `<form method="POST" action="/${basePath}/${escapeHtml(roomId)}/${escapeHtml(token)}/destroy" style="margin:0"
onsubmit="return confirm('Destroy this room now? All messages, questions, the contract and every link will be deleted immediately and irreversibly.')">
<button class="destroy" type="submit">✕ Destroy room</button></form>`;
}
/** Shown after a member destroyed the room. */
export function destroyedPage(): string {
return page(
'Room destroyed',
`<h1>Room destroyed</h1>
<p>The room and all its data (messages, questions, contract, tokens, observer link) have been deleted. Nothing remains on the server.</p>
<p class="hint"><a href="/create">Create a new rendezvous</a></p>`,
);
}
/** Read-only observer page for the human: same information, plus whose turn it is. */ /** 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)
? `<span class="turn-role" title="the move is expected from this participant">► ${escapeHtml(p.role)}</span>`
: escapeHtml(p.role),
)
.join(', ');
const msgs = o.conversation const msgs = o.conversation
.map( .map(
(m) => (m) =>
@@ -178,10 +207,10 @@ export function observerHtmlPage(o: ObserverView): string {
const contract = o.current_contract; const contract = o.current_contract;
return page( return page(
o.room.title, o.room.title,
`<h1>${escapeHtml(o.room.title)}</h1> `<div class="topbar"><h1>${escapeHtml(o.room.title)}</h1>${destroyForm(o.room.id, token, 'o')}</div>
<p><b>Status:</b> ${escapeHtml(o.room.status)} · <b>Goal:</b> ${escapeHtml(o.room.goal || '(not set)')} · <b>Expires:</b> ${escapeHtml(o.room.expires_at)}</p> <p><b>Status:</b> ${escapeHtml(o.room.status)} · <b>Goal:</b> ${escapeHtml(o.room.goal || '(not set)')} · <b>Expires:</b> ${escapeHtml(o.room.expires_at)}</p>
<p><b>Participants:</b> ${o.participants.map((p) => escapeHtml(p.role)).join(', ')} — you are watching as <b>observer</b> (read-only).</p> <p><b>Participants:</b> ${roleList} — you are watching as <b>observer</b> (read-only).</p>
<div class="q blocking"><b>Whose turn:</b> ${escapeHtml(o.turn)}</div> <div class="q turn"><b>Whose turn:</b> ${escapeHtml(o.turn)}</div>
<h2>Open questions</h2>${openQs || '<p class="hint">(none)</p>'} <h2>Open questions</h2>${openQs || '<p class="hint">(none)</p>'}
${resolvedQs ? `<h2>Resolved questions</h2>${resolvedQs}` : ''} ${resolvedQs ? `<h2>Resolved questions</h2>${resolvedQs}` : ''}
<h2>Agreed Contract (v${contract ? contract.version : 0})</h2> <h2>Agreed Contract (v${contract ? contract.version : 0})</h2>
@@ -282,6 +311,7 @@ The observer URL (${baseUrl}/o/<room>/<observer-token>, also as .md) shows the n
- PUT ${baseUrl}/api/rooms/<room_id>/contract — {"markdown":"## Facts\\n..."} — proposes a NEW version all participants must agree to. - PUT ${baseUrl}/api/rooms/<room_id>/contract — {"markdown":"## Facts\\n..."} — proposes a NEW version all participants must agree to.
- POST ${baseUrl}/api/rooms/<room_id>/agree — agree to the current contract version; when everyone agreed to the same version AND no blocking questions remain, status becomes agreed. - POST ${baseUrl}/api/rooms/<room_id>/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/<room_id>/final.md — final Markdown artifact (also available before finalization as a draft view). - GET ${baseUrl}/api/rooms/<room_id>/final.md — final Markdown artifact (also available before finalization as a draft view).
- DELETE ${baseUrl}/api/rooms/<room_id> — 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) ## Negotiation protocol (expected agent behavior)
@@ -306,7 +336,7 @@ The observer URL (${baseUrl}/o/<room>/<observer-token>, also as .md) shows the n
} }
/** Read-only human view of the negotiation (same token auth as the API). */ /** 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 const msgs = v.conversation
.map( .map(
(m) => (m) =>
@@ -328,7 +358,7 @@ export function roomHtmlPage(v: RoomView): string {
const contract = v.current_contract; const contract = v.current_contract;
return page( return page(
v.room.title, v.room.title,
`<h1>${escapeHtml(v.room.title)}</h1> `<div class="topbar"><h1>${escapeHtml(v.room.title)}</h1>${destroyForm(v.room.id, token, 'r')}</div>
<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>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> <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>'} <h2>Open questions</h2>${openQs || '<p class="hint">(none)</p>'}
+33
View File
@@ -116,6 +116,17 @@ test('HTTP: cannot read room by id without token; unknown routes 404; human page
// wrong observer token -> forbidden // wrong observer token -> forbidden
assert.equal((await fetch(`${baseUrl}/o/${created.room_id}/nope`)).status, 403); 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}`); const r = await fetch(`${baseUrl}/api/rooms/${created.room_id}`);
assert.equal(r.status, 403); assert.equal(r.status, 403);
const r2 = await fetch(`${baseUrl}/api/rooms/${created.room_id}?token=${created.participants[0].token}`); 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/); assert.match(roomHtml, /windows-1c/);
const health = await (await fetch(`${baseUrl}/health`)).json(); const health = await (await fetch(`${baseUrl}/health`)).json();
assert.ok(health.ok); 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 { } finally {
app.close(); app.close();
} }