// Create a thread with a prefix chosen by its title, and fail loudly instead of silently.
// For a user API key (XF-Api-Key header). Uses the built-in fetch (tested on Node 20).
// baseUrl is your board's API root, e.g. https://example.com/api. Take baseUrl and apiKey from your
// server's own config, never from tool input, so the model can't choose where the key is sent.
const ENTITIES = { '&': '&', '<': '<', '>': '>', '"': '"', ''': "'" };
// the API sends prefix titles HTML-escaped ("Q&A" arrives as "Q&A"); compare them as plain text
const plain = (s) => String(s).replace(/&(?:amp|lt|gt|quot|#039);/g, (e) => ENTITIES[e]).trim().toLowerCase();
const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]']);
export async function createThreadWithPrefix({ baseUrl, apiKey, nodeId, prefixTitle, title, message, timeoutMs = 15000 }) {
const idText = typeof nodeId === 'number' || typeof nodeId === 'string' ? String(nodeId).trim() : '';
if (!/^[1-9]\d{0,9}$/.test(idText)) throw new Error(`Invalid forum id: ${nodeId}`); // 2 or "2", nothing else
const forumId = Number(idText);
for (const [name, value] of Object.entries({ prefixTitle, title, message })) {
if (typeof value !== 'string' || !value.trim()) throw new Error(`Missing ${name}`);
}
const key = typeof apiKey === 'string' ? apiKey.trim() : ''; // a key read from a file often ends in a newline
if (!/^[\x21-\x7e]+$/.test(key)) throw new Error('Invalid apiKey: check your config'); // never echo the key
const root = String(baseUrl).replace(/\/+$/, '');
let rootUrl;
try { rootUrl = new URL(root); } catch { throw new Error('Invalid baseUrl: check your config'); } // it may hold a password
if (rootUrl.username || rootUrl.password) throw new Error('baseUrl must not contain a user name or password');
if (rootUrl.protocol !== 'https:' && !(rootUrl.protocol === 'http:' && LOOPBACK.has(rootUrl.hostname))) {
throw new Error(`Refusing to send the API key to ${root}: use https`);
}
const call = async (path, init = {}) => {
let res;
try {
res = await fetch(`${root}/${path}`, {
...init,
redirect: 'manual', // following one could send the key, and even the POST, to another address
headers: { 'XF-Api-Key': key },
signal: AbortSignal.timeout(timeoutMs),
});
} catch (e) {
if (e.name === 'TimeoutError') throw e;
throw new Error(`${path}: ${e.cause?.code ?? e.message}`, { cause: e }); // e.g. ECONNREFUSED, ENOTFOUND
}
if (res.status >= 300 && res.status < 400) {
let to = res.headers.get('location') ?? '';
try { if (to) to = new URL(to, `${root}/${path}`).href; } catch { /* keep it as sent */ }
const hint = to.endsWith(`/${path}`) ? `try baseUrl ${to.slice(0, -path.length - 1)}` : 'baseUrl must be the API root, ending in /api';
throw new Error(`${path}: HTTP ${res.status} redirect to ${to || '(no location)'} (${hint})`);
}
const json = await res.json().catch((e) => {
if (e.name === 'TimeoutError') throw e;
return null; // an HTML error page is not JSON
});
if (!res.ok || !json) throw new Error(`${path}: HTTP ${res.status} ${JSON.stringify(json?.errors ?? null)}`);
return json;
};
const { forum } = await call(`forums/${forumId}/`);
if (!forum) throw new Error(`forums/${forumId}/: no forum in the response (is baseUrl the API root?)`);
const matches = (forum.type_data?.prefixes ?? []).filter((p) => plain(p.title) === plain(prefixTitle));
const prefix = matches.find((p) => p.is_usable) ?? matches[0];
if (!prefix) throw new Error(`No prefix titled "${prefixTitle}" is enabled in forum ${forumId}`);
if (!prefix.is_usable) throw new Error(`Prefix "${prefixTitle}" is not usable by this account`);
const body = new URLSearchParams({ node_id: forumId, title, message, prefix_id: prefix.prefix_id });
let thread;
try {
({ thread } = await call('threads/', { method: 'POST', body }));
} catch (e) {
if (/: HTTP 4\d\d \[/.test(e.message)) throw e; // the API refused it with a JSON 4xx error: nothing was created
throw new Error(`${e.message} (the thread may have been created: check the forum before retrying)`, { cause: e });
}
if (!thread) throw new Error('threads/: no thread in the response (the thread may have been created: check the forum before retrying)');
if (thread.prefix_id !== prefix.prefix_id) {
throw new Error(`Thread ${thread.thread_id} was created WITHOUT the "${prefixTitle}" prefix`);
}
return thread;
}