Files
stack/packages/webui/src/chat-http.mjs
T
jason.woltje 4429a104f6 webui: Q175 phase 1, the Console verifies SSO itself under --sso, OIDC RP with oauth4webapi 3.8.8 (row 192, #1690)
Squash of Launchpad's launchpad/row192 53bb59462..37582e820, manifest round 2
6f8cee147713c53652b58d47c5efd15ed47da1efbef6a126464857269b0b7787 (18 paths).
Sage's gate on 088087ac9: package suites 2758 pass 0 fail, scripts/test-*.sh
429 pass 0 fail; mutants 22 of 23 killed, #23 (second origin check) declared
equivalent by Launchpad (sage-1407). New dependency oauth4webapi 3.8.8: the
canonical checkout and any stack copy need npm ci. No live change; the live
re-pin is a window with Mos (mos-1099 item 3).
2026-10-20 16:21:29 -05:00

133 lines
7.3 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 STATUS = {
'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,
'tester-off': 403, 'tester-required': 403, 'tester-refused': 403,
};
function safeMessage(code) {
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;
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 match = /^\/api\/chat\/threads\/([^/]+)\/(messages|open)$/.exec(path);
if (match) {
thread = match[1];
if (!id(thread)) throw Error();
}
if (write) {
query(search, []);
const v = await payload(req);
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']) || !id(v.project) || !name(v.name)) throw Error();
action = 'createThread'; args = v;
} else if (match?.[2] === 'messages') {
if (!exact(v, ['body']) || typeof v.body !== 'string' || !v.body.trim() || v.body.length > 32768) throw Error();
action = 'send'; args = { thread, body: v.body };
} else if (match?.[2] === 'open') {
if (!exact(v, [])) throw Error();
action = 'open'; args = { thread };
} else return refuse(404, 'not-found');
} else if (path === '/api/chat/projects') {
query(search, []); action = 'projects'; args = {};
} else if (path === '/api/chat/threads') {
const q = query(search, ['project']);
if (q.has('project') && !id(q.get('project'))) throw Error();
action = 'threads'; args = q.has('project') ? { project: q.get('project') } : {};
} else if (match?.[2] === 'messages') {
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 = 'messages'; args = { thread, limit: q.has('limit') ? Number(q.get('limit')) : 100 };
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 (!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 (!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);
}
}