@formshield/ai
Open-source TypeScript spam filter using rules-first scoring and optional multi-model AI for form submissions.
Features
- 🛡️ Rules-first approach: Cheap, fast heuristics catch obvious spam
- 🤖 Multi-model AI: Optional OpenAI, Anthropic, Ollama for edge cases
- 🔒 Privacy-focused: Hash PII before AI, GDPR-friendly
- ⚡ Edge-compatible: Works on Vercel Edge, Cloudflare Workers, Node.js
- 🎯 Field-agnostic: Works with any form structure
- 💰 Cost-optimized: AI only for gray-zone submissions
- 🔐 Prompt-injection safe: Strict JSON schema, conservative fallbacks
Installation
npm install @formshield/ai
Optional peer dependencies:
npm install openai
npm install @anthropic-ai/sdk
Quick Start
Basic Usage (Rules Only)
import { createFormShield } from '@formshield/ai';
const shield = createFormShield();
const decision = await shield.evaluate({
email: 'user@example.com',
name: 'John Doe',
message: 'I am interested in your services.',
url: 'https://yoursite.com/contact',
userAgent: req.headers['user-agent'],
});
console.log(decision);
With OpenAI (Fallback Strategy)
import { createFormShield, openAiProvider } from '@formshield/ai';
const shield = createFormShield({
aiProviders: [openAiProvider(process.env.OPENAI_API_KEY!)],
router: { mode: 'first-available', order: ['openai'] },
grayBand: [45, 65],
});
Multi-Model Voting
import { createFormShield, openAiProvider, anthropicProvider } from '@formshield/ai';
const shield = createFormShield({
aiProviders: [
openAiProvider(process.env.OPENAI_API_KEY!),
anthropicProvider(process.env.ANTHROPIC_API_KEY!),
],
router: {
mode: 'vote',
members: ['openai', 'anthropic'],
minAgree: 2,
},
});
Fallback with Budget Control
const shield = createFormShield({
aiProviders: [
openAiProvider(process.env.OPENAI_API_KEY!),
stubProvider('backup'),
],
router: {
mode: 'fallback',
primary: 'openai',
secondary: 'backup',
},
aiBudget: {
perRequestUsd: 0.01,
rollingUsd: 10.0,
},
});
Configuration
type Config = {
allowDomains?: string[];
blockDomains?: string[];
disposableDomains?: string[];
allowEmailsHashed?: string[];
blockEmailsHashed?: string[];
blockKeywords?: string[];
tldRisk?: Record<string, number>;
aiProviders?: AiProvider[];
router?: RouterStrategy;
grayBand?: [number, number];
aiBudget?: { perRequestUsd?: number; rollingUsd?: number };
piiPolicy?: 'hash-local' | 'plain';
cacheTtlMs?: number;
enableMxCheck?: boolean;
customRules?: Rule[];
};
Router Strategies
none
Rules-only, no AI classification.
first-available
Use the first provider in order.
{ mode: 'first-available', order: ['openai', 'anthropic'] }
fallback
Try primary, fall back to secondary on error/timeout.
{ mode: 'fallback', primary: 'openai', secondary: 'anthropic' }
vote
Majority vote across multiple providers.
{ mode: 'vote', members: ['openai', 'anthropic', 'ollama'] }
blend
Weighted average of confidence scores.
{
mode: 'blend',
members: [
{ id: 'openai', weight: 2 },
{ id: 'anthropic', weight: 1 }
]
}
canary
Send % of traffic to candidate model for evaluation.
{ mode: 'canary', control: 'openai', candidate: 'new-model', pct: 10 }
ab
Split traffic by hash for A/B testing.
{ mode: 'ab', a: 'openai', b: 'anthropic', salt: 'test' }
Custom Rules
import type { Rule } from '@formshield/ai';
const blockCompetitors: Rule = ({ normalized }) => {
const domain = normalized.email?.split('@')[1];
if (domain === 'competitor.com') {
return { action: 'block', score: 0, reasons: ['competitor'] };
}
return null;
};
const shield = createFormShield({
customRules: [blockCompetitors],
});
Built-in Rules
import {
rulePhoneLooksFake,
ruleUrlOnlyMessage,
ruleCompanyVsDomainMismatch,
ruleExcessiveCaps,
ruleCryptoSpam,
ruleSeoSpam,
} from '@formshield/ai';
Field Descriptors
For better heuristics, provide field type hints:
await shield.evaluate({
fields: {
firstName: 'John',
lastName: 'Doe',
company: 'Acme Inc',
email: 'john@acme.com',
phone: '+1-555-123-4567',
message: 'I need help with...',
},
descriptors: [
{ key: 'firstName', type: 'name' },
{ key: 'lastName', type: 'name' },
{ key: 'company', type: 'company' },
{ key: 'email', type: 'email', required: true },
{ key: 'phone', type: 'phone' },
{ key: 'message', type: 'message', required: true },
],
});
Examples
Next.js API Route (Edge)
import { createFormShield, openAiProvider } from '@formshield/ai';
const shield = createFormShield({
aiProviders: [openAiProvider(process.env.OPENAI_API_KEY!)],
router: { mode: 'first-available', order: ['openai'] },
});
export const runtime = 'edge';
export async function POST(req: Request) {
const body = await req.json();
const decision = await shield.evaluate({
email: body.email,
name: body.name,
message: body.message,
url: req.url,
userAgent: req.headers.get('user-agent') || undefined,
timestampMs: Date.now(),
});
if (decision.action === 'block') {
return Response.json({ error: 'Submission blocked' }, { status: 403 });
}
if (decision.action === 'review') {
await queueForReview(body, decision);
}
return Response.json({ success: true });
}
Express.js
import express from 'express';
import { createFormShield, anthropicProvider } from '@formshield/ai';
const app = express();
const shield = createFormShield({
aiProviders: [anthropicProvider(process.env.ANTHROPIC_API_KEY!)],
router: { mode: 'first-available', order: ['anthropic'] },
});
app.post('/api/contact', async (req, res) => {
const decision = await shield.evaluate({
email: req.body.email,
name: req.body.name,
message: req.body.message,
url: req.get('origin'),
userAgent: req.get('user-agent'),
ip: req.ip,
});
if (decision.action === 'block') {
return res.status(403).json({ error: 'Spam detected' });
}
res.json({ success: true });
});
Local AI with Ollama
import { createFormShield, ollamaProvider } from '@formshield/ai';
const shield = createFormShield({
aiProviders: [
ollamaProvider('mistral:7b', 'http://localhost:11434')
],
router: { mode: 'first-available', order: ['ollama'] },
});
Development
npm install
npm test
npm run build
npm run lint
License
MIT
Contributing
Contributions welcome! Please open an issue or PR.