Files
stack/packages/webui/src/chat-http.mjs
T

235 lines
15 KiB
JavaScript

// HTTP validation and write fence local to chat; existing Console routes keep their own fences.
import { BusReadError, sendOutcome } from './bus.mjs';
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const KEY = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,159}$/;
const id = value => typeof value === 'string' && UUID.test(value);
const name = value => typeof value === 'string' && value.length > 0 && value.length <= 200 && !/[\r\n\0]/.test(value);
const one = (req, key) => req.headersDistinct[key]?.length === 1 ? req.headersDistinct[key][0] : null;
const json = (res, status, value) => {
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
res.end(JSON.stringify(value));
};
const ROOM_LINES = Object.freeze({
'unknown-addressee': 'Unknown addressee. Choose a room member.',
'addressee-ambiguous': "That addressee is ambiguous. Use a room member's instance name.",
'room-thread-name-taken': 'A room thread name is already in use. Resolve the conflicting thread before opening the room.',
'room-not-open': 'This room is not open. Open the room before sending.',
});
const STATUS = {
...Object.fromEntries(Object.keys(ROOM_LINES).map(code => [code, 409])),
'channel-disabled': 503,
'invalid-request': 400, 'unknown-repo': 400, 'message-not-found': 400,
'project-not-found': 404, 'thread-not-found': 404, 'thread-not-owned': 403,
'project-exists': 409, 'thread-exists': 409, 'human-required': 403, unauthenticated: 403,
'unknown-business': 403, 'read-only': 403, 'not-configured': 503, 'no-bus-host': 503,
'thread-instance-missing': 503, 'thread-instance-ambiguous': 503, 'outcome-unknown': 503,
'session-not-found': 404, 'operation-not-found': 404, 'session-exists': 409,
'session-op-conflict': 409, 'session-clear-complete': 409, 'session-name-too-long': 409, 'session-clearing': 409, 'session-revision-stale': 409,
'session-retired': 409, 'processing-halted': 409, 'confirm-required': 400,
'unknown-verb': 503, 'channel-operation': 503,
'tester-off': 403, 'tester-required': 403, 'tester-refused': 403,
// FED R6 (#1800): the broker's refusals of a granted user.
'share-not-covered': 403, 'share-exists': 409, 'share-not-found': 404,
'access-refused': 403, 'access-revoked': 401,
};
function safeMessage(code) {
if (Object.hasOwn(ROOM_LINES, code)) return ROOM_LINES[code];
if (code === 'unknown-verb' || code === 'channel-operation') return 'This session operation is unavailable until its dependency is installed.';
if (code === 'channel-disabled') return 'Run mosaic plugins to read the revision, then mosaic plugins set --file PATCH with plugins.channel set to ["cli","webui"].';
if (code === 'thread-not-owned') return 'This thread is not available to this user.';
if (code === 'outcome-unknown') return 'Outcome unknown. Read the current list or conversation before trying again. Nothing was retried.';
return `Chat operation refused (${code}).`;
}
async function payload(req) {
if ((req.headers['content-type'] || '').split(';')[0].trim().toLowerCase() !== 'application/json') throw Error();
const chunks = []; let size = 0;
for await (const chunk of req) {
size += chunk.length;
if (size > 16384) throw Error();
chunks.push(chunk);
}
const value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
if (!value || typeof value !== 'object' || Array.isArray(value)) throw Error();
return value;
}
function query(search, allowed) {
const q = new URLSearchParams(search);
if ([...q.keys()].some(k => !allowed.includes(k) || q.getAll(k).length !== 1)) throw Error();
return q;
}
const exact = (value, allowed, required = allowed) =>
Object.keys(value).every(k => allowed.includes(k)) && required.every(k => Object.hasOwn(value, k));
export async function chatHTTP(req, res, { path, search, chat, named, send, preview, log }) {
const write = req.method === 'POST';
let action, args, thread, room;
const refuse = (status, code, sent = false) => json(res, status, {
error: code, message: safeMessage(code), ...(write ? { sent } : {}),
});
if (!['GET', 'POST'].includes(req.method)) return refuse(405, 'method-not-allowed');
if (write) {
if (preview?.tester) {
// Row 177 (#1675): the tester listener's chat calls the bus as the tester, so there is no
// sign-in; the Origin is the listener's own, as on its other writes.
if (!chat) return refuse(403, 'read-only');
if (one(req, 'origin') !== preview.origin) return refuse(403, 'cross-origin');
} else {
if (!send || !named || !preview) return refuse(403, 'read-only');
// Q175 phase 1 (#1690): under --sso the session was checked before this route.
if (preview.sso !== true && one(req, preview.header) !== preview.user) return refuse(401, 'unauthenticated');
if (one(req, 'origin') !== (preview.origin ?? `https://${preview.host}`)) return refuse(403, 'cross-origin');
}
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site');
}
try {
const roomMatch = /^\/api\/chat\/rooms\/([^/]+)\/(open|messages)$/.exec(path);
if (roomMatch) {
room = roomMatch[1];
if (!id(room)) throw Error();
if (preview?.tester) return refuse(403, 'human-required');
}
const match = /^\/api\/chat\/threads\/([^/]+)\/(messages|open|sessions|clear|cancel|operation|shares|share|unshare)$/.exec(path);
if (match) {
thread = match[1];
if (!id(thread)) throw Error();
}
if (write && preview?.tester && ['sessions', 'clear', 'cancel'].includes(match?.[2])) return refuse(403, 'read-only');
if (write) {
query(search, []);
const v = await payload(req);
if (roomMatch) {
if (roomMatch[2] === 'open') {
if (!exact(v, [])) throw Error();
action = 'roomOpen'; args = { project: room };
} else {
if (!exact(v, ['body']) || typeof v.body !== 'string' || !v.body.trim() || v.body.length > 32768) throw Error();
action = 'roomSend'; args = { project: room, body: v.body };
}
} else if (path === '/api/chat/projects') {
if (!exact(v, ['name', 'repo'], ['name']) || !name(v.name) ||
(Object.hasOwn(v, 'repo') && (typeof v.repo !== 'string' || !KEY.test(v.repo)))) throw Error();
action = 'createProject'; args = v;
} else if (path === '/api/chat/threads') {
if (!exact(v, ['project', 'name', 'instance'], ['project', 'name']) || !id(v.project) || !name(v.name) ||
(Object.hasOwn(v, 'instance') && (typeof v.instance !== 'string' || !KEY.test(v.instance)))) throw Error();
action = 'createThread'; args = v;
} else if (['share', 'unshare'].includes(match?.[2])) {
if (!exact(v, ['to']) || typeof v.to !== 'string' || !v.to.length || v.to.length > 200 || /[\r\n\0]/.test(v.to)) throw Error();
action = match[2]; args = { thread, to: v.to };
} else if (match?.[2] === 'sessions') {
if (!exact(v, ['name', 'op']) || !name(v.name) || !KEY.test(v.op ?? '')) throw Error();
action = 'createSession'; args = { thread, name: v.name, op: v.op };
} else if (match?.[2] === 'cancel') {
if (!exact(v, ['op', 'confirm']) || !KEY.test(v.op ?? '') || v.confirm !== true) throw Error();
action = 'cancelSession'; args = { thread, op: v.op, confirm: true };
} else if (match?.[2] === 'clear') {
if (!exact(v, ['session', 'revision', 'confirm', 'op']) || !id(v.session) ||
!Number.isSafeInteger(v.revision) || v.revision < 0 || v.confirm !== true || !KEY.test(v.op ?? '')) throw Error();
action = 'clearSession'; args = { thread, ...v };
} else if (match?.[2] === 'messages') {
if (!exact(v, ['body', 'session'], ['body']) || (Object.hasOwn(v, 'session') && !id(v.session)) || typeof v.body !== 'string' || !v.body.trim() || v.body.length > 32768) throw Error();
action = 'send'; args = { thread, body: v.body, ...(v.session ? { session: v.session } : {}) };
} else if (match?.[2] === 'open') {
if (!exact(v, ['session'], []) || (Object.hasOwn(v, 'session') && !id(v.session))) throw Error();
action = 'open'; args = { thread, ...(v.session ? { session: v.session } : {}) };
} else return refuse(404, 'not-found');
} else if (roomMatch) {
if (roomMatch[2] !== 'messages') return refuse(405, 'method-not-allowed');
const q = query(search, ['after', 'limit']);
if (q.has('after') && !id(q.get('after'))) throw Error();
if (q.has('limit') && (!/^[1-9]\d*$/.test(q.get('limit')) || Number(q.get('limit')) > 200)) throw Error();
action = 'roomRead'; args = { project: room, limit: q.has('limit') ? Number(q.get('limit')) : 100, ...(q.has('after') ? { after: q.get('after') } : {}) };
} else if (path === '/api/chat/projects') {
query(search, []); action = 'projects'; args = {};
} else if (path === '/api/chat/threads') {
const q = query(search, ['project', 'shared']);
if (q.has('shared') && q.get('shared') !== 'true') throw Error();
if (q.has('project') && !id(q.get('project'))) throw Error();
action = 'threads'; args = { ...(q.has('project') ? { project: q.get('project') } : {}), ...(q.has('shared') ? { shared: true } : {}) };
} else if (match?.[2] === 'shares') {
query(search, []); action = 'shares'; args = { thread };
} else if (match?.[2] === 'sessions') {
query(search, []); action = 'sessions'; args = { thread };
} else if (match?.[2] === 'operation') {
const q = query(search, ['op']);
if (!q.has('op') || !KEY.test(q.get('op'))) throw Error();
action = 'sessionOperation'; args = { thread, op: q.get('op') };
} else if (match?.[2] === 'messages') {
const q = query(search, ['after', 'limit', 'session']);
if (q.has('session') && !id(q.get('session'))) throw Error();
if (q.has('after') && !id(q.get('after'))) throw Error();
if (q.has('limit') && (!/^[1-9]\d*$/.test(q.get('limit')) || Number(q.get('limit')) > 200)) throw Error();
action = 'messages'; args = { thread, limit: q.has('limit') ? Number(q.get('limit')) : 100 };
if (q.has('session')) args.session = q.get('session');
if (q.has('after')) args.after = q.get('after');
} else return refuse(404, 'not-found');
} catch { return refuse(400, 'invalid-request'); }
if (!chat) return refuse(503, 'not-configured');
const line = outcome => log(`${new Date().toISOString()} chat action=${action} user=${JSON.stringify(preview?.user ?? null)} ${outcome}`);
try {
const value = await chat[action](args);
if (action === 'roomOpen' || action === 'roomSend') {
const valid = action === 'roomOpen'
? value?.project === room && Array.isArray(value.threads) && value.threads.length > 0 && value.threads.every(t => id(t?.id) && typeof t.instance === 'string' && KEY.test(t.instance) && t.name === t.instance)
&& new Set(value.threads.map(t => t.id)).size === value.threads.length && new Set(value.threads.map(t => t.instance)).size === value.threads.length
: Array.isArray(value?.ids) && value.ids.length > 0 && value.ids.every(id) && new Set(value.ids).size === value.ids.length
&& Array.isArray(value.to) && value.to.length === value.ids.length && value.to.every(k => typeof k === 'string' && KEY.test(k)) && new Set(value.to).size === value.to.length;
if (!valid) { line('error=outcome-unknown'); return refuse(503, 'outcome-unknown', 'unknown'); }
line(`project=${room}`);
return json(res, 200, action === 'roomOpen'
? { project: room, threads: value.threads.map(({ id, instance, name }) => ({ id, instance, name })) }
: { ids: value.ids, to: value.to });
}
if (action === 'roomRead') {
if (!Array.isArray(value) || value.some(m => !id(m?.id) || !Array.isArray(m.ids) || !m.ids.length || m.ids[0] !== m.id || !m.ids.every(id)
|| new Set(m.ids).size !== m.ids.length || !Array.isArray(m.to) || new Set(m.to).size !== m.to.length || m.to.length !== m.ids.length || !m.to.every(k => typeof k === 'string' && KEY.test(k)))) return refuse(502, 'invalid-response');
return json(res, 200, { rows: value, after: value.at(-1)?.ids.at(-1) ?? args.after ?? null, hasMore: value.length === args.limit, at: new Date().toISOString() });
}
if (action === 'sessionOperation' || action === 'clearSession' || action === 'cancelSession') {
// Only this operation's receipt crosses the HTTP seam. Never expose native paths/proofs.
if (!value || value.thread !== args.thread || value.op !== args.op || !id(value.session) ||
!id(value.successor) || (action === 'clearSession' && value.session !== args.session) ||
!['clearing', 'uncertain', 'retired', 'failed', 'cancelled'].includes(value.state) ||
![null, 'held', 'started', 'refused'].includes(value.start) ||
(value.state === 'cancelled' && value.retired !== null && value.retired !== true) ||
(action === 'cancelSession' && value.state !== 'cancelled') ||
!(value.refusal === null || typeof value.refusal === 'string' && /^[a-z-]{1,64}$/.test(value.refusal))) {
if (write) line('error=outcome-unknown');
return refuse(write ? 503 : 502, write ? 'outcome-unknown' : 'invalid-response', write ? 'unknown' : false);
}
if (write) line(`op=${args.op}`);
const { op, thread, session, successor, state, start, refusal, at } = value;
return json(res, 200, { op, thread, session, successor, state, start, refusal, at, ...(state === 'cancelled' ? { retired: value.retired } : {}) });
}
if (!write) {
if (!Array.isArray(value)) return refuse(502, 'invalid-response');
const page = action === 'messages' ? {
after: value.at(-1)?.id ?? args.after ?? null, hasMore: value.length === args.limit,
} : {};
return json(res, 200, { rows: value, ...page, at: new Date().toISOString() });
}
if (action === 'share' || action === 'unshare') {
if (value?.thread !== thread || value?.to !== args.to || value?.op !== action) {
line('error=outcome-unknown'); return refuse(503, 'outcome-unknown', 'unknown');
}
line(`thread=${thread} op=${action}`);
return json(res, 200, { thread, to: value.to, op: value.op });
}
if (!value || (action === 'open' ? value.opened !== thread : !id(value.id)) ||
(action === 'createThread' && (typeof value.instance !== 'string' || !KEY.test(value.instance)))) {
line('error=outcome-unknown');
return refuse(503, 'outcome-unknown', 'unknown');
}
line(`id=${action === 'open' ? thread : value.id}`);
// Only the fields of this operation's receipt are exposed.
const receipt = action === 'open' ? { opened: thread } : { id: value.id };
if (action === 'createThread') receipt.instance = value.instance;
return json(res, 200, { ...receipt, at: new Date().toISOString() });
} catch (err) {
const outcome = write ? sendOutcome(err) : { code: err instanceof BusReadError ? err.code : 'invalid-response' };
// Unrecognized transport text is never reflected. Known refusal codes have fixed messages.
const code = Object.hasOwn(STATUS, outcome.code) ? outcome.code : 'invalid-response';
if (write) line(`error=${code}`);
return refuse(STATUS[code] ?? 502, code, outcome.sent);
}
}