235 lines
15 KiB
JavaScript
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);
|
|
}
|
|
}
|