Files
stack/packages/webui/src/chat-http.mjs
T
jason.woltjeandClaude Opus 5.5 008746b6ed plugins: channel kind for the CLI and Console chat paths (row 164, #1661)
Adds plugin kind `channel` (interfaceVersion 1, dispatch(request, {call}))
with cli and webui modules. Project and thread surfaces and thread
talk/follow in the CLI and the Console chat go through the selected
channel. An empty or absent channel selection is off: the CLI exits 3 and
the Console answers 503, each naming `mosaic plugins` and `mosaic plugins
set` with the business selector. Management plugins and plugins.set stay
on the core transport. The loader gains a synchronous snapshot(kind)
getter; loaded(), modules() and last-good handling are unchanged. No lock
is held across dispatch. serve.mjs, public/ and the bus are untouched.

Owner Rocko. Reviewer Dewey: r1 changes (comment 28980, B1: the recovery
sentence was never asserted), r2 approve (comment 28984) on candidate
manifest ae2fcfda...1fc8, the B1 assert killing mutant D1. Design accepted
by Mos (mos-0768, mos-0773). Fixture rewrite at project.test.mjs:47 acked
by Mos (mos-0789) on Dewey's line-by-line check: lines 48-51 identical,
one added assert. Sage gate on e5b10d275: 25 steps green, 2081 package tests, 156 queue node tests, 422 script selftests, 0 failed.

Live stays as pinned. The re-pin that takes this live puts `mosaic
plugins set` in Jason's window right after activate (mos-0768).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 14:24:42 -05:00

124 lines
6.7 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,
};
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 (!send || !named || !preview) return refuse(403, 'read-only');
if (one(req, preview.header) !== preview.user) return refuse(401, 'unauthenticated');
if (one(req, '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);
}
}