AI features are expensive. When I launched PulsePress with 11 Gemini-powered features, I had a daily quota of 900 requests. Within 48 hours, users discovered they could spam "Generate social media captions" on random URLs—burning through my quota by 2 PM. I needed abuse prevention without killing the user experience. Here's the strike system I built, including the auto-reset mechanism that keeps false positives from becoming permanent bans.
The Problem: AI Feature Abuse
My AI features cost money per request. Google Gemini charges per token, and I had three abuse vectors:
- Non-news content: Users submitting promotional sites, e-commerce pages, random blogs
- Spam requests: Hammering "Generate Summary" 50 times on the same article
- Quota farming: Bots testing API endpoints
The goal: Block bad actors without punishing legitimate users who make honest mistakes.
The Three-Strike Architecture
I built a progressive penalty system:
Strikes 1-2: Warning only (logged, no blocking)
Strike 3: 15-minute cooldown
Strike 4: 20-minute cooldown
Strike 5: 30-minute cooldown
Strike 6+: 2-day block
Key insight: Cooldowns escalate, but never permanently ban. Even chronic abusers get reset after 48 hours.
Implementation: Database Schema
I track strikes directly in the User model:
// UserSchema.ts
{
email: string,
newsClassificationStrikes: [
{
violationType: 'search_query' | 'article_summary' | 'ai_enhancement',
strikeTimestamp: Date,
strikeCount: number,
blockUntil?: Date,
blockType?: 'cooldown' | 'long_block',
}
],
createdAt: Date,
}
Why embed in the User model? Fast lookups—no joins needed on every AI request.
Step 1: Content Classification
Before processing any AI request, I classify the content:
// NewsClassificationService.ts
const classifyContent = async (text: string): Promise<Classification> => {
const prompt = `
Analyze this content and determine if it's news/journalism.
Content: ${text}
Return JSON:
{
"classification": "news" | "non_news",
"confidence": 0.0-1.0,
"reason": "brief explanation",
}
Non-news includes: ads, e-commerce, personal blogs, promotional content.
`;
const result = await callGeminiWithFallback(AI_MODELS, prompt);
return JSON.parse(result);
};
Example classifications:
// News
{
"classification": "news",
"confidence": 0.95,
"reason": "Breaking news about policy change",
}
// Non-news
{
"classification": "non_news",
"confidence": 0.87,
"reason": "E-commerce product page for shoes",
}
Step 2: Strike Application
If classification returns non_news, apply a strike:
// StrikeService.ts
const applyStrike = async (userId: string, violationType: ViolationType): Promise<StrikeResult> => {
const user = await User.findOne({ userExternalId: userId });
if (!user) throw new Error('User not found');
// Get existing strikes
const strikes = user.newsClassificationStrikes || [];
const latestStrike = strikes[strikes.length - 1];
// Calculate new strike count
let newStrikeCount = latestStrike ? latestStrike.strikeCount + 1 : 1;
// Determine penalty
const penalty = calculatePenalty(newStrikeCount);
// Create strike record
const newStrike = {
violationType,
strikeTimestamp: new Date(),
strikeCount: newStrikeCount,
blockUntil: penalty.blockUntil,
blockType: penalty.blockType,
};
// Update user
await User.updateOne(
{ userExternalId: userId },
{ $push: { newsClassificationStrikes: newStrike } },
);
// Log violation
await NonNewsViolationLog.create({
userExternalId: userId,
email: user.email,
violationType,
content: text.substring(0, 2000), // Truncate for storage
violatedAt: new Date(),
});
return {
strikeCount: newStrikeCount,
blockType: penalty.blockType,
blockUntil: penalty.blockUntil,
message: penalty.message,
};
};
Step 3: Penalty Calculation
// strikeHelpers.ts
const calculatePenalty = (strikeCount: number): Penalty => {
if (strikeCount <= 2) {
return {
blockType: null,
blockUntil: null,
message: 'Warning: Detected non-news content',
};
}
if (strikeCount >= 6) {
const blockUntil = new Date();
blockUntil.setDate(blockUntil.getDate() + 2); // 2 days
return {
blockType: 'long_block',
blockUntil,
message: 'Account blocked for 2 days due to repeated violations',
};
}
// Strikes 3-5: Escalating cooldowns
const cooldownMinutes = 15 + ((strikeCount - 3) * 5); // 15, 20, 25, 30 min
const blockUntil = new Date();
blockUntil.setMinutes(blockUntil.getMinutes() + cooldownMinutes);
return {
blockType: 'cooldown',
blockUntil,
message: `Cooldown active for ${cooldownMinutes} minutes`,
};
};
Step 4: Enforcement
Check strike status before every AI request:
// AIController.ts
router.post('/summarize', authenticateUser, async (req, res) => {
const { url } = req.body;
const userId = req.user.userExternalId;
// 1. Check if user is blocked
const user = await User.findOne({ userExternalId: userId });
const latestStrike = user.newsClassificationStrikes.slice(-1)[0];
if (latestStrike?.blockUntil && latestStrike.blockUntil > new Date()) {
return res.status(403).json({
success: false,
message: `Account blocked until ${latestStrike.blockUntil}`,
strikeCount: latestStrike.strikeCount,
blockType: latestStrike.blockType,
});
}
// 2. Classify content
const content = await scrapeArticle(url);
const classification = await classifyContent(content.text);
// 3. Apply strike if non-news
if (classification.classification === 'non_news') {
const strikeResult = await applyStrike(userId, 'article_summary');
return res.status(400).json({
success: false,
message: 'Non-news content detected',
...strikeResult,
});
}
// 4. Process legitimate request
const summary = await SummarizationService.summarize(url);
return res.json({ success: true, data: summary });
});
Step 5: Auto-Reset (The Secret Sauce)
Strikes auto-reset after 48 hours. I use a cron job:
// server.ts
import cron from 'node-cron';
// Run every 15 minutes
cron.schedule('*/15 * * * *', async () => {
const cutoffTime = new Date();
cutoffTime.setHours(cutoffTime.getHours() - 48);
// Find users with strikes older than 48 hours
const users = await User.find({
'newsClassificationStrikes.strikeTimestamp': { $lt: cutoffTime }
});
for (const user of users) {
const oldestStrike = user.newsClassificationStrikes[0];
const timeSinceStrike = Date.now() - oldestStrike.strikeTimestamp.getTime();
if (timeSinceStrike >= 48 * 60 * 60 * 1000) {
// Reset strikes
await User.updateOne(
{ _id: user._id },
{ $set: { newsClassificationStrikes: [] } },
);
console.log(`Reset strikes for user ${user.email}`);
}
}
});
Why 48 hours? Long enough to deter abuse, short enough to forgive honest mistakes.
User Experience: Strike Status Endpoint
Users can check their status:
// GET /api/v1/strikes/status
{
"success": true,
"data": {
"strikeCount": 2,
"isBlocked": false,
"blockType": null,
"blockUntil": null,
"nextStrikeConsequence": "15-minute cooldown",
"resetIn": "36 hours",
}
}
Edge Cases Handled
1. Rapid-fire requests
- Rate limiter (30 requests per 5 minutes)
- Separate from the strike system
2. Shared accounts
- Strikes tied to user ID, not IP
- Each user has an independent strike count
What I'd Do Differently
1. Add strike appeal mechanism
- In-app button to dispute strikes
- Auto-review with different AI models
2. Tiered quotas
- Free users: Strict strike system
- Paid users: More lenient (higher strike threshold)
3. ML-based pattern detection
- Flag users with suspicious request patterns
- Block before they burn quota
Tech Stack
- Backend: Node.js + Express + TypeScript
- Database: MongoDB (User, NonNewsViolationLog collections)
- AI: Google Gemini (content classification)
- Cron: node-cron (auto-reset)
- Auth: JWT (Clerk integration)
GitHub: https://github.com/chayan-1906/PulsePress-Node.js
Abuse prevention doesn't require complex ML. A three-strike system with escalating penalties and auto-reset solves 95% of cases.
Questions? Drop them below. I'm happy to discuss strike system design or share more implementation details.
Top comments (0)