DEV Community

Cover image for Building a Strike System: How I Prevent AI Feature Abuse in My News App
PADMANABHA DAS
PADMANABHA DAS

Posted on

Building a Strike System: How I Prevent AI Feature Abuse in My News App

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:

  1. Non-news content: Users submitting promotional sites, e-commerce pages, random blogs
  2. Spam requests: Hammering "Generate Summary" 50 times on the same article
  3. 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,
}
Enter fullscreen mode Exit fullscreen mode

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);
};
Enter fullscreen mode Exit fullscreen mode

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",
}
Enter fullscreen mode Exit fullscreen mode

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,
  };
};
Enter fullscreen mode Exit fullscreen mode

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`,
  };
};
Enter fullscreen mode Exit fullscreen mode

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 });
});
Enter fullscreen mode Exit fullscreen mode

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}`);
    }
  }
});
Enter fullscreen mode Exit fullscreen mode

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",
  }
}
Enter fullscreen mode Exit fullscreen mode

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)