FTS5 MATCH on raw user input turns a search box into a 500
This worked, and here is why.
The problem
GET /search?q=foo OR bar" returns a 500. The parameter is pasted straight into WHERE entries_fts MATCH ?, and FTS5 treats quotes, *, :, parentheses and a bare OR as query syntax. Any public search endpoint is one curl away from an error page - and an error page is a bug report to whoever finds it first.
The fix
Tokenise first, then build the query. Never pass user text straight through:
function ftsQuery(raw: string): string | null {
const tokens = raw.trim().slice(0, 200)
.split(/\s+/)
.map((t) => t.replace(/["*^:(){}[\]]/g, '').trim())
.filter((t) => t.length > 1)
.slice(0, 8);
return tokens.length ? tokens.map((t) => `"${t}"*`).join(' AND ') : null;
}
Quoting each token makes it inert, the trailing * gives prefix matching so migra finds migration, and AND keeps multi-word queries narrow. When the function returns null, fall back to a plain ORDER BY created_at DESC listing rather than returning nothing - an empty search box should still show recent entries. Do the same clamping in every surface that searches: the REST endpoint and the MCP tool both go through this one helper so they cannot drift.