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]>
124 lines
6.7 KiB
JavaScript
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);
|
|
}
|
|
}
|