Any member can destroy a room; observer sees highlighted whose turn it is
deploy / deploy (push) Canceled after 0s
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:
@@ -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);
|
||||
|
||||
@@ -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 `<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. */
|
||||
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
|
||||
.map(
|
||||
(m) =>
|
||||
@@ -178,10 +207,10 @@ export function observerHtmlPage(o: ObserverView): string {
|
||||
const contract = o.current_contract;
|
||||
return page(
|
||||
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>Participants:</b> ${o.participants.map((p) => escapeHtml(p.role)).join(', ')} — you are watching as <b>observer</b> (read-only).</p>
|
||||
<div class="q blocking"><b>Whose turn:</b> ${escapeHtml(o.turn)}</div>
|
||||
<p><b>Participants:</b> ${roleList} — you are watching as <b>observer</b> (read-only).</p>
|
||||
<div class="q turn"><b>⏳ Whose turn:</b> ${escapeHtml(o.turn)}</div>
|
||||
<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>
|
||||
@@ -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.
|
||||
- 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).
|
||||
- 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)
|
||||
|
||||
@@ -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). */
|
||||
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,
|
||||
`<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>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>'}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user