ingestion: reject submissions containing disallowed phrases

/api/ai/submit and /api/submission-receive now scan incoming payload
title + body against the bb.disallowed_phrases allowlist (cached 3
min) and return 422 with the matching phrases if any are found. Stops
competitor mentions from re-entering the pipeline after the bulk
purge.
This commit is contained in:
Ryan Salazar
2026-05-27 13:34:41 +00:00
parent 95a230b1ef
commit 1793c4f0e2
3 changed files with 93 additions and 0 deletions

View File

@@ -0,0 +1,65 @@
/**
* Server-side ingestion guard. Used by /api/ai/submit and /api/submission-receive
* to reject incoming submissions that contain a disallowed phrase from the
* bb.disallowed_phrases table (competitor publications, banned exhibitors,
* etc.).
*/
import { createAdminClient } from "./supabase/admin";
type Phrase = {
phrase: string;
category: string;
match_mode: "word_boundary" | "substring" | "domain";
};
let cached: { rows: Phrase[]; expires: number } | null = null;
const CACHE_TTL_MS = 3 * 60 * 1000;
async function loadActivePhrases(): Promise<Phrase[]> {
if (cached && cached.expires > Date.now()) return cached.rows;
try {
const svc = createAdminClient();
const { data } = await svc
.from("disallowed_phrases")
.select("phrase, category, match_mode")
.eq("active", true);
const rows = (data || []) as Phrase[];
cached = { rows, expires: Date.now() + CACHE_TTL_MS };
return rows;
} catch {
return cached?.rows || [];
}
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export type Violation = { phrase: string; category: string };
/** Returns the list of phrases the text violates (empty if clean). */
export async function findViolations(text: string): Promise<Violation[]> {
if (!text) return [];
const phrases = await loadActivePhrases();
const out: Violation[] = [];
for (const p of phrases) {
const esc = escapeRegex(p.phrase);
let re: RegExp;
if (p.match_mode === "word_boundary") {
re = new RegExp(`(^|[^A-Za-z0-9])(${esc})(?![A-Za-z0-9])`, "i");
} else if (p.match_mode === "domain") {
re = new RegExp(`(?:https?://)?(?:[\\w-]+\\.)?${esc}\\b`, "i");
} else {
re = new RegExp(esc, "i");
}
if (re.test(text)) out.push({ phrase: p.phrase, category: p.category });
}
return out;
}
/** Throw-style helper: short-circuits an API route on first violation. */
export async function assertNoViolations(text: string): Promise<{ ok: true } | { ok: false; violations: Violation[] }> {
const v = await findViolations(text);
return v.length === 0 ? { ok: true } : { ok: false, violations: v };
}