From 1793c4f0e202a9a226d0b978c58718178e9d0a78 Mon Sep 17 00:00:00 2001 From: Ryan Salazar Date: Wed, 27 May 2026 13:34:41 +0000 Subject: [PATCH] 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. --- src/app/api/ai/submit/route.ts | 16 ++++++ src/app/api/submission-receive/route.ts | 12 +++++ src/lib/disallowed-phrases-guard.ts | 65 +++++++++++++++++++++++++ 3 files changed, 93 insertions(+) create mode 100644 src/lib/disallowed-phrases-guard.ts diff --git a/src/app/api/ai/submit/route.ts b/src/app/api/ai/submit/route.ts index 368abe1..e59ee37 100644 --- a/src/app/api/ai/submit/route.ts +++ b/src/app/api/ai/submit/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { ingestRawSubmission, rewriteSubmission } from "@/lib/ai/rewrite-pipeline"; +import { findViolations } from "@/lib/disallowed-phrases-guard"; export const runtime = "nodejs"; export const maxDuration = 300; @@ -98,6 +99,21 @@ export async function POST(req: NextRequest) { ); } + // Reject submissions whose title or body contains a disallowed phrase + // (competitor publications, banned exhibitors, etc.). Logged with the + // violation list so the sender knows what to remove. + const scanText = `${payload.rawTitle}\n${payload.rawContent}\n${payload.rawHtml || ""}`; + const violations = await findViolations(scanText); + if (violations.length > 0) { + return NextResponse.json( + { + error: "Submission rejected: contains disallowed phrases", + violations, + }, + { status: 422 }, + ); + } + const submissionId = await ingestRawSubmission(payload); const outcome = await rewriteSubmission({ submissionId, personaSlug: payload.personaSlug }); diff --git a/src/app/api/submission-receive/route.ts b/src/app/api/submission-receive/route.ts index 521ca8c..df4dbab 100644 --- a/src/app/api/submission-receive/route.ts +++ b/src/app/api/submission-receive/route.ts @@ -1,5 +1,6 @@ import { NextResponse, type NextRequest } from 'next/server'; import { createClient } from '@supabase/supabase-js'; +import { findViolations } from '@/lib/disallowed-phrases-guard'; export const dynamic = 'force-dynamic'; @@ -36,6 +37,17 @@ export async function POST(request: NextRequest) { const content = (body?.body || '').toString(); if (!title || !content) return NextResponse.json({ error: 'title and body are required' }, { status: 400 }); + // Editorial guardrail: reject anything mentioning a disallowed phrase + // (competitor publications, banned exhibitors, etc.) before it ever lands + // in bb.native_articles. + const violations = await findViolations(`${title}\n${content}`); + if (violations.length > 0) { + return NextResponse.json( + { error: 'Submission rejected: contains disallowed phrases', violations }, + { status: 422 }, + ); + } + const contributor = body?.contributor || {}; const featuredImage = body?.featured_image_url || null; const attachments = Array.isArray(body?.attachments) ? body.attachments : []; diff --git a/src/lib/disallowed-phrases-guard.ts b/src/lib/disallowed-phrases-guard.ts new file mode 100644 index 0000000..7203a1e --- /dev/null +++ b/src/lib/disallowed-phrases-guard.ts @@ -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 { + 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 { + 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 }; +}